Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions plan/Plan-001-plugin-discovery/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
type: index
title: "Plan-001 — Plugin discovery"
description: "Contents of the Plan-001 bundle."
okf_version: "0.1"
---

# Plan-001 — Plugin discovery

## Contents

- [Plan-001: Plugin discovery](./plan.md) - Plan overview, DAG, tracks, gates, test plan.
- [Task-001: FR-008 candidate search core](./tasks/Task-001-candidate-search-core.md)
- [Task-002: FR-008 dedupe, rank, signal, token redaction](./tasks/Task-002-dedupe-rank-signal.md)
- [Task-003: FR-009 compatibility verification](./tasks/Task-003-compatibility-verification.md)
- [Task-004: FR-010 TTL cache + createPluginSearch factory](./tasks/Task-004-cache-and-factory.md)
- [Task-005: FR-011 GitHub rate-limit surfacing + short-circuit](./tasks/Task-005-rate-limit.md)
- [Task-006: FR-012 sourceToInstallInput](./tasks/Task-006-source-to-install-input.md)
- [Task-007: re-exports + quality gates](./tasks/Task-007-exports-and-gates.md)
- [Task-008: publish hand-off gate](./tasks/Task-008-publish-handoff.md)
11 changes: 11 additions & 0 deletions plan/Plan-001-plugin-discovery/log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
type: log
title: "Plan-001 — Update Log"
description: "Chronological log of changes to the Plan-001 bundle."
---

# Plan-001 — Update Log

## History

- **2026-06-27** — Plan created from the reviewed discovery spec (US-003, FR-008…FR-012, NFR-005); scaffolded Task-001…Task-008 across tracks A (search), B (cache/rate), C (helper), S (integration/release). Test plan traces TC-022…TC-054 in `spec/tests.md`. Publish (Task-008) flagged as the hand-off gate before filament-ide consumes the API.
94 changes: 94 additions & 0 deletions plan/Plan-001-plugin-discovery/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
id: Plan-001
title: "Plugin discovery (search.ts) — candidate search, verification, cache, rate-limit"
type: Plan
status: active
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/usecase/US-003
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-009
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-010
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-011
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-012
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-005
type: references
---

# Implementation Plan: Plugin discovery (`search.ts`)

## Requirements Summary

- [ ] **US-003**: A host consumes a discovery API to find and verify publishable plugins by tag.
- [ ] **FR-008**: Candidate search across npm + GitHub via an injectable `HttpFetcher`; per-backend `Promise.allSettled`; normalize to `Source`; encode/headers/auth; malformed-body tolerance; all-fail shape; `limit` clamp; dedupe + total-order ranking; `signal`; token redaction (CON-2).
- [ ] **FR-009**: Host-driven compatibility verification — manifest fetch (unpkg / raw.githubusercontent), `404`→incompatible vs transient→error, `verify` null/object/throw, concurrency cap ≤ 6.
- [ ] **FR-010**: `createTtlCache` (injectable `Clock`) + `createPluginSearch` factory; cache key incl. verifier-presence + token-id; no caching of errored responses; `invalidate`; late-bound token.
- [ ] **FR-011**: GitHub rate-limit surfacing (`resetAt` epoch-seconds) + short-circuit while exhausted + first-call passthrough; never sleeps.
- [ ] **FR-012**: `sourceToInstallInput` renders a `Source` to its canonical source string.
- [ ] **NFR-005**: All discovery network access through one injectable `HttpFetcher`; suite runs offline; zero new deps; 100% coverage (NFR-002).

Spec-side reconciliation of NFR-001/NFR-003/`spec.md` (resolution surface = sync; discovery = the one async surface) is already landed; Task-007 verifies the code matches.

## Dependency Graph

- `Task-001 (FR-008 core search) -> Task-002 (FR-008 dedupe/rank/signal/redaction)`
Reason: merge/rank operate on the normalized candidate results Task-001 produces.
- `Task-001 -> Task-003 (FR-009 verification)`
Reason: verification filters the candidates Task-001 returns.
- `Task-001, Task-003 -> Task-004 (FR-010 cache + factory)`
Reason: `createPluginSearch` wraps the full search+verify pipeline and caches its `SearchResponse`.
- `Task-001, Task-004 -> Task-005 (FR-011 rate-limit)`
Reason: rate state is read from GitHub responses (Task-001) and retained by the factory (Task-004).
- `Task-006 (FR-012 sourceToInstallInput)` is independent — only depends on the `Source` type — and runs in parallel.
- `Task-002, Task-003, Task-004, Task-005, Task-006 -> Task-007 (exports + gates)`
- `Task-007 -> Task-008 (publish hand-off gate)`

### Execution Tracks

- **Track A — search pipeline (serial):** Task-001 → Task-002, and Task-001 → Task-003.
- **Track B — cache & rate (serial, after A):** Task-004 → Task-005.
- **Track C — helper (parallel):** Task-006.
- **Track S — integration/release (serial, last):** Task-007 → Task-008.

### Hand-off contract (to filament-ide)

This kit must be **specced + implemented + published** before filament-ide's
discovery work (its US-016/FR-030…032) consumes the published `search` API.
**Task-008 (publish) is the gate**: filament-ide bumps to the version that exports
`searchPlugins`/`createPluginSearch`/`sourceToInstallInput` only after it lands on
npm.

## Test Plan

All discovery tests live in a new `tests/search.test.ts` (vitest), driven by an
injected fake `HttpFetcher` and `Clock` — no real npm/GitHub/unpkg/raw access
(NFR-005). TC IDs trace to `spec/tests.md` (TC-022…TC-054).

### Unit (fake fetcher + clock)

- [ ] **candidate search** (FR-008 AC-1..4,6,7,8 / CON-1): TC-022, TC-023, TC-024, TC-025, TC-043, TC-044, TC-045.
- [ ] **dedupe / rank / signal / redaction** (FR-008 AC-5,9,10 / CON-2): TC-026, TC-046, TC-047, TC-054.
- [ ] **verification** (FR-009 AC-1..8): TC-027, TC-028, TC-029, TC-030, TC-031, TC-048, TC-049, TC-050.
- [ ] **cache + factory** (FR-010 AC-1..7): TC-032, TC-033, TC-034, TC-035, TC-036, TC-051, TC-052.
- [ ] **rate-limit** (FR-011 AC-1..5): TC-037, TC-038, TC-039, TC-040, TC-053.
- [ ] **sourceToInstallInput** (FR-012 AC-1,2): TC-041, TC-042.
- [ ] **defaultHttpFetcher** (NFR-005): `vi.stubGlobal('fetch', spy)` delegation.

## Quality Gates

- `make test` — vitest at the **100%** branches/functions/lines/statements gate (NFR-002) over `src/**` including `src/search.ts`.
- `make lint` — eslint + prettier clean.
- `quire validate --scope . "spec/**/*.md" "plan/**/*.md"` — spec + plan validate.
- Zero runtime dependencies preserved (`package.json` `dependencies` stays empty; default fetcher uses global `fetch`).

## Notes

`search.ts` is the kit's first asynchronous, networked surface; everything is
bounded behind `HttpFetcher`/`Clock` injection so the suite stays offline and
deterministic. The kit performs **no** manifest parsing — the host `CandidateVerifier`
owns that — keeping the toolkit framework-agnostic.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
id: Task-001
title: "FR-008 candidate search core (search.ts scaffold + HttpFetcher + backends)"
type: Task
status: done
track: A
priority: P0
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/usecase/US-003
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-005
type: references
---

# Task-001: FR-008 candidate search core

## Scope

Stand up `src/search.ts` with the discovery type surface and the `HttpFetcher`
seam, and implement candidate search across npm and GitHub with independent
backends, normalization to `Source`, defensive parsing, the all-fail shape, and
`limit` clamping. (Dedupe/rank/signal/redaction are Task-002.)

## Subtasks

- [ ] Write Vitest specs FIRST in `tests/search.test.ts` with an injected fake `HttpFetcher`:
TC-022 (merge both backends), TC-023 (encoded `keywords:`/`topic:` + `size`/`per_page`),
TC-024 (one backend rejects → other returns + `SearchBackendError`), TC-025 (`Authorization`
iff token; `sources:["npm"]` skips GitHub), TC-043 (all backends fail → `results:[]` + per-backend
errors), TC-044 (malformed body / missing optionals degrade, no throw), TC-045 (`limit` clamp 250/100).
- [ ] Define types: `HttpResponse`, `HttpFetcher`, `defaultHttpFetcher` (global `fetch`), `SearchBackend`,
`PluginSearchResult`, `RateLimit`, `SearchBackendError`, `SearchResponse`, `SearchOptions`,
`CandidateVerifier`, `SearchError`.
- [ ] Implement `searchPlugins(opts)`: per-backend `Promise.allSettled`; npm
`GET {npmRegistry}/-/v1/search?text=keywords:{tag}[ {query}]&size={limit}`; GitHub
`GET {githubApi}/search/repositories?q=topic:{tag}[ {query}]&per_page={limit}` with
`Accept`/`X-GitHub-Api-Version` and conditional `Authorization`; URL-encode the query.
- [ ] Normalize npm→`{type:"npm",package}` and github→`{type:"github",repo:full_name}`; tolerate missing
`objects[]`/`items[]` (→ `SearchBackendError`) and missing optional item fields (→ `undefined`).
- [ ] Clamp `limit` to npm `size` ≤ 250 and GitHub `per_page` ≤ 100.

## Deliverables

- `src/search.ts` with the type surface, `defaultHttpFetcher`, and `searchPlugins` candidate path.
- `tests/search.test.ts` covering TC-022, TC-023, TC-024, TC-025, TC-043, TC-044, TC-045 (all via fake fetcher).

## Notes

CON-1: every request goes through the injected `HttpFetcher`; tests assert no real
network. Feeds Task-002 (dedupe/rank) and Task-003 (verification).
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
id: Task-002
title: "FR-008 dedupe, total-order rank, signal propagation, token redaction"
type: Task
status: done
track: A
priority: P0
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008
type: references
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001
type: depends_on
---

# Task-002: FR-008 dedupe, rank, signal, token redaction

## Scope

Complete `searchPlugins` with cross-backend dedupe, a deterministic total-order
ranking, `AbortSignal` propagation, and the token-redaction guarantee.

## Subtasks

- [ ] Write Vitest specs FIRST: TC-026 (npm vs github of same project collapse, npm preferred, carries
repo stars/updatedAt), TC-046 (total-order rank — absent stars/updatedAt sort last, `fullName`
tie-break), TC-047 (`signal` passed to every fetch; abort → backend error, resolved results kept),
TC-054 (token never in cache key, error, or result).
- [ ] Implement URL normalization for dedupe (lowercase host/owner/repo; strip `git+`, scheme, trailing
`/`, `.git`); prefer the npm entry and copy the matched repo's `stars`/`updatedAt` onto it.
- [ ] Implement ranking: `stars` desc → `updatedAt` desc → `fullName` asc (absent values lowest).
- [ ] Thread `opts.signal` into every `HttpFetcher` call; map an abort to a `SearchBackendError`.
- [ ] Ensure `githubToken` never appears in cache keys, `SearchBackendError` messages, or results (CON-2).

## Deliverables

- Dedupe + ranking + signal + redaction in `src/search.ts`.
- Tests TC-026, TC-046, TC-047, TC-054.

## Notes

Ranking must be a total order so the 100% gate has a deterministic result order to
assert. Depends on Task-001's normalized candidate results.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
id: Task-003
title: "FR-009 host-driven compatibility verification"
type: Task
status: done
track: A
priority: P0
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-009
type: references
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001
type: depends_on
---

# Task-003: FR-009 compatibility verification

## Scope

Add the optional verification pass to `searchPlugins`: fetch each candidate's
manifest from a rate-limit-friendly CDN, hand the raw text to the host
`CandidateVerifier`, and keep/drop accordingly — distinguishing an absent manifest
(`404` → incompatible) from a transient failure (→ unverified + error). The kit
parses nothing.

## Subtasks

- [ ] Write Vitest specs FIRST: TC-027 (verify returns object → kept, `verified:true` + capabilities),
TC-028 (verify null → dropped), TC-029 (`404` → dropped as incompatible, no error, verify not called),
TC-030 (npm via `unpkg.com/{pkg}/{manifestPath}`, github via `raw.githubusercontent.com/{owner}/{repo}/HEAD/{manifestPath}`),
TC-031 (no verifier → unfiltered, no fetch), TC-048 (non-404 fail/reject → dropped + transient
`SearchBackendError`), TC-049 (verify throws → only that candidate dropped, no escape),
TC-050 (≤ 6 concurrent manifest fetches for > 6 candidates).
- [ ] Implement manifest-fetch URL construction (unpkg / raw.githubusercontent HEAD) through the same `HttpFetcher`.
- [ ] Branch on status: `404`→incompatible-drop (no error); other non-OK/reject→unverified-drop + transient error.
- [ ] Branch on `verify` result: `null`→drop; object→`verified:true` + `capabilities`; throw→drop that candidate only.
- [ ] Cap manifest-fetch concurrency at ≤ 6 simultaneous calls.

## Deliverables

- Verification pass in `src/search.ts`.
- Tests TC-027..TC-031, TC-048, TC-049, TC-050.

## Notes

The kit performs NO YAML/JSON parsing — interpretation is entirely the host
callback (keeps the toolkit framework-agnostic). Depends on Task-001's candidates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: Task-004
title: "FR-010 TTL cache + createPluginSearch factory"
type: Task
status: done
track: B
priority: P0
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-010
type: references
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001
type: depends_on
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-003
type: depends_on
---

# Task-004: FR-010 TTL cache + createPluginSearch factory

## Scope

Add the generic injectable-clock TTL cache and the `createPluginSearch` factory
that wraps the full search+verify pipeline, holding cache + rate state across calls.

## Subtasks

- [ ] Write Vitest specs FIRST (injected `Clock`): TC-032 (return before expiry, evict after clock advances),
TC-033 (`max` evicts oldest), TC-034 (cache hit → no `HttpFetcher` call), TC-035 (`invalidate(opts)`
re-fetches; `invalidate()` clears all), TC-036 (late-bound `githubToken` resolved per call, no stale
hit under previous token-id), TC-051 (errored/rate-limited response NOT cached), TC-052
(verifier-presence + token-id discriminate entries).
- [ ] Implement `Clock`, `systemClock`, `createTtlCache({ttlMs, clock?, max?})` with `get/set/delete/clear/size`.
- [ ] Implement `createPluginSearch(deps)` → `{ search, invalidate, lastRate }`; cache key
`tag|query|sources|limit|verifier-present|token-id` (token-id is a non-secret discriminator, never the raw token).
- [ ] Cache only responses whose `errors` is empty; resolve a function-valued `githubToken` per call.

## Deliverables

- `createTtlCache`, `Clock`/`systemClock`, `createPluginSearch` in `src/search.ts`.
- Tests TC-032..TC-036, TC-051, TC-052.

## Notes

Not caching errored responses is what preserves the FR-011 resume-after-`resetAt`
behavior (Task-005). Depends on the search (Task-001) + verify (Task-003) pipeline
it wraps.
44 changes: 44 additions & 0 deletions plan/Plan-001-plugin-discovery/tasks/Task-005-rate-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
id: Task-005
title: "FR-011 GitHub rate-limit surfacing + short-circuit"
type: Task
status: done
track: B
priority: P0
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-011
type: references
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001
type: depends_on
- target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-004
type: depends_on
---

# Task-005: FR-011 GitHub rate-limit surfacing + short-circuit

## Scope

Read GitHub rate-limit headers into `rate.github`, surface an exhausted window as a
`SearchBackendError` (never throw, never sleep), and short-circuit further GitHub
requests through `createPluginSearch` while the window is exhausted.

## Subtasks

- [ ] Write Vitest specs FIRST (injected `Clock` + fake fetcher): TC-037 (200 headers populate `rate.github`),
TC-038 (`403`/`429` + `remaining:0` → `SearchBackendError{rateLimited:true}` + reset, no throw),
TC-039 (exhausted window → next `search` skips GitHub while clock < `resetAt`), TC-040 (clock past
`resetAt` → GitHub re-issued), TC-053 (first call, no prior snapshot → GitHub issued; `lastRate()` empty).
- [ ] Parse `x-ratelimit-limit|remaining|reset` (present on 200 and 403); store `resetAt` as epoch-seconds.
- [ ] Compare `clock.now()/1000 < resetAt` for the short-circuit; emit the rate-limited error in `errors`.
- [ ] Guard the first-call (undefined `lastRate().github`) path so it does not short-circuit.
- [ ] No sleep/delay primitive — behavior is a pure function of inputs + the injected clock.

## Deliverables

- Rate-limit parsing + short-circuit in `src/search.ts`.
- Tests TC-037..TC-040, TC-053.

## Notes

Cooperates with Task-004's retained rate state. `resetAt` is epoch-seconds while
`clock.now()` is ms — the `/1000` conversion is the unit bridge.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
id: Task-006
title: "FR-012 sourceToInstallInput (canonical source string)"
type: Task
status: done
track: C
priority: P1
relationships:
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-012
type: references
- target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-001
type: references
---

# Task-006: FR-012 sourceToInstallInput

## Scope

Add `sourceToInstallInput(source)` rendering a typed `Source` to its canonical
source string (the token a host's string-based install entry point accepts).

## Subtasks

- [ ] Write Vitest specs FIRST: TC-041 (npm→`package`, github→`owner/repo`), TC-042 (git/url→`url`, path→`path`).
- [ ] Implement the per-variant mapping; `git-subdir`→`url` (documented lossy — subdir not expressible as one token).

## Deliverables

- `sourceToInstallInput` in `src/search.ts`.
- Tests TC-041, TC-042.

## Notes

Independent of the search pipeline — only needs the `Source` union (FR-001), so it
runs in parallel (Track C).
Loading
Loading