Repository navigation
Conversation
… rule Phase 0 stores declarations on an immutable release, but every catalog row today is a legacy listing with no release, no artifact and no digest. A read surface built only on release declarations would answer `unknown` for the entire catalog, so every compatibility filter and badge would be permanently inert. Adds the one rule that bridges the two storage tiers: a release declaration wins and only it can reach `verified`; otherwise the listing declaration is used, which can reach at most `declared` because a listing has no digest to bind evidence to; neither present yields `unknown`. A release declaration arriving without a release is ignored rather than trusted, so no surface can claim release-grade provenance it cannot prove. The engine still decides; this only chooses the inputs, and the Phase 2 removal is local to selectDeclaration.
Adds the listing-level compatibility declaration. A legacy listing has no release and no digest, so this column is the only place a publisher can declare runtime support for the catalog that exists today; it can reach `declared` but never `verified`. Adding the column pushed schema.ts to 237 LOC, over the project's 200 LOC rule, so the five auth/API-key tables move to `auth-schema.ts` following the same per-domain pattern as registry-schema.ts and collections-schema.ts. Nothing in those tables references `skills`, so the split crosses no foreign key, and schema.ts re-exports them so `~/lib/db/schema` stays the single import site. Migration 0010 was generated against a backed-up local D1 (.wrangler/backups/skillx-db-PRE-0010-*.sqlite) and applied cleanly: skills went from 28 to 29 columns. A second generate reports "No schema changes", which is what proves the auth split is schema-neutral.
Adds the listing adapter over the shared precedence rule, and the
`--compatible <runtime>` filter.
The filter cannot run in the database: compatibility is a JSON column that FTS5
and Vectorize cannot index. It runs over a projected page instead, and it reports
what it did rather than filtering silently — a filter that hides undecidable
listings makes an agent believe the catalog is smaller than it is. Each run
returns `{ evaluated, matched, undecided, refused, poolExhausted }`, and an empty
page carries a note stating that a missing declaration is not support. When the
filter is active the executor over-fetches a candidate pool and truncates only
after matching, so a page does not come back short just because the top-ranked
rows were undeclared.
`GET /api/skills/:slug?target=runtime@version` now returns every runtime the
listing declares plus a version-aware answer for the requested target, so an agent
can read the requirement, the evidence and the refusal reason without scraping
HTML. Only `declared` and `verified` are actionable; `unknown` never is.
Agents drive this CLI, so the exit code is part of the interface: 0 when a target
may be acted on, 4 when it may not, 2 for a bad invocation, 3 for a missing skill,
5 for credentials, 6 for the network. `--json` emits one envelope shape
(`{ ok, data }` or `{ ok: false, error: { code, message, details?, hint? } }`) so a
caller never parses human text.
- `skillx inspect <id> [--target runtime[@Version]]` reports metadata, every
declared runtime, the requirements, the evidence and the refusal reasons.
- `skillx check --target <runtime[@Version]> <id>` is the gate, and its exit code
is the answer.
- `skillx search <query> --compatible <runtime>` adds the filter column and prints
what the filter did, so a short page is never a mystery.
Identifier parsing and slug mapping move to `lib/identifier.ts` so `use`,
`inspect` and `check` cannot disagree about which skill an identifier names. That
also brings `use.ts` from 207 to 173 LOC, under the project's 200 LOC rule.
Reads stay read-only: `inspect` and `check` never register or scan.
A badge alone cannot tell a reader whether anyone ran the skill on the artifact they are about to install, so the panel separates the two tiers. `verified` names the probe and the date it was recorded against this artifact; `declared` says plainly that no probe exists. An undeclared listing renders an explicit warning instead of no badge. Silence would read as support, and this catalog declares nothing today, so that is the state almost every page is in. The loader test pins the wiring, including that adding compatibility did not weaken the protected payload boundary: a free listing still carries content and a protected one still does not.
Agents should not have to parse HTML to read a skill, so the catalog now answers in Markdown at three routes: - GET /skills/:slug.md one skill, with its SKILL.md body when granted - GET /llms.txt the catalog index, in one request - GET /llms-full.txt listed skills with their bodies, bounded to 200 The skill page advertises its Markdown variant through rel=alternate, and the Markdown route answers with a canonical Link header. These are the most payload-heavy surfaces in the product, so they run through the protected resolver rather than around it. The renderer takes `payloadGranted` as a required input instead of inferring it from whether `content` is a string: an inference would make a deliberate denial indistinguishable from a forgotten field. A withheld body renders as an explicit note, never a blank section that would read as an empty skill. llms.txt and llms-full.txt read their rows in one gated module (lib/catalog/public-catalog-index.ts), which is now covered by the boundary guard's "must actually call the gate" invariant. Both report the catalog total from a count query rather than the size of their own slice, so a truncated document cannot tell an agent the catalog ends where the slice does. Verified: pnpm typecheck clean; pnpm test 286 passed / 22 files, including the 25 new Markdown-rendering tests and 6 for the gated index; pnpm build emits the three routes as server-only resource routes.
"Copy as Markdown" fetches the page's own /skills/:slug.md resource instead of assembling Markdown in the browser. That is the security property, not a shortcut: the route applies the protected payload boundary server-side, so a viewer without entitlement copies the withheld-body document and the client cannot be handed a payload the server would not have served. It also keeps one Markdown renderer rather than two that can drift. The button reports a failed copy instead of doing nothing, since silence would look like a successful copy of an empty document. Also adds route-level tests for the Markdown loader. The renderer's unit tests pin the rendering rules and the static guard proves every serve path calls the gate, but neither exercises this handler end to end. The new tests drive the real loader and assert on the real body: a free listing serves its body, a protected one never does — anonymously or with a session but no entitlement — a withheld listing is still fully described, malformed compatibility JSON never becomes a claim, and an absent slug is 400 rather than a partial document. skill-detail.tsx was sitting exactly at the 200-line ceiling, so the risk banners moved to components/risk-warning-banner.tsx. They keep `danger` and `caution` distinct rather than collapsing into one warning. Verified: pnpm typecheck clean; pnpm test 296 passed / 23 files; pnpm build ok.
Slices 1-5 are done; 6 (MCP/WebMCP) and 7 (parity tests) have not started. The notes state the two things slice 5 does not prove — no browser-level test of rel=alternate hoisting or the clipboard write — so their absence is not mistaken for coverage.
POST /api/mcp speaks JSON-RPC 2.0 over the MCP streamable-HTTP transport and implements initialize, notifications/initialized, tools/list, and tools/call. Everything else answers `method not found`, which is more useful to a client than a stub that appears to work. Tools: search_packages, get_package, get_release, check_compatibility, get_kit. Each handler is a thin adapter over the same executors the HTTP API and the CLI use, so MCP cannot drift into a second implementation. Three decisions worth reviewing: - A tool that cannot answer returns `found: false` with a reason code rather than an error. `no_package` is the expected answer for most of today's catalog because the registry tables are empty until a later phase, and an agent has to be able to tell "not in the registry" apart from "the call failed". - A tool failure is a JSON-RPC *success* carrying isError, per MCP; only a malformed request or unknown method is a JSON-RPC error. - get_kit reports a private collection as `not_found`, identical to an unknown slug, so the tool cannot be used to enumerate private slugs. The endpoint deliberately sets no CORS headers. MCP clients are not browsers and the WebMCP layer runs same-origin, so nothing in scope needs cross-origin access; opening it would only widen the surface. Tool descriptions are treated as load-bearing: each states what the tool will not do, including that `unknown` compatibility is not support and that `compatible` takes a runtime but not a version. Verified: pnpm typecheck clean; pnpm test 330 passed / 25 files, including 24 tests for the protocol and dispatch and 10 driving the route over real HTTP bodies (id 0 is a call, notifications get 202 and no body, unknown tool is a success with isError). handlers.ts was 230 lines at first and is now split into args/packages/kits/registry, all under 200.
Adds WebMCP as progressive enhancement: when navigator.modelContext exists the page registers three read tools (search_skills, get_skill, check_skill_compatibility); when it does not, the bridge renders nothing and the site behaves identically. WebMCP is a draft API, so detection checks the shape of the object rather than trusting presence. A renamed or partially implemented modelContext falls back to "unavailable" instead of throwing inside a page effect, and a registration that throws is reported as `failed`, never as `registered` — claiming success for a rejected registration would make the failure invisible. Each tool calls the same public HTTP endpoint the page itself uses, rather than importing server executors. One implementation means an in-page agent cannot get a different answer from the API, and the protected payload boundary stays in exactly one place: the server. `fetchImpl` is injected so the tools are testable without a live network. Verified: pnpm typecheck clean; pnpm test 348 passed / 26 files, including 18 for detection, registration, and the tools' request shapes; pnpm build ok.
Phase 1's claim is that API, CLI, MCP, and Markdown give one answer for one listing because they delegate to one engine. Unit tests inside each surface cannot show that: they would keep passing if a surface quietly grew its own rule. These tests compare the surfaces to each other, so a divergence fails here. Covered against one fixture and one target: - the web adapter is byte-identical to the engine for five declarations (none, in range, explicit unsupported, out of range, unparseable range), proving the adapter adds no rule of its own; - the real MCP tool, driven through callTool with only storage stubbed, returns the engine's status and reasons unchanged, and reports a missing listing as `not_found` rather than collapsing it into `unknown`; - the Markdown renderer prints the same status the engine returned, built through the real `toCompatibilitySummary` adapter, and never claims `verified` for a listing that has no evidence. Two honest limits, recorded in the file rather than left implicit. The CLI is not imported here because apps/web/tsconfig.cloudflare.json lists its own files only, so a cross-package import fails the project boundary; the CLI's rule is pinned by value in its own test. And that leaves the CLI as the one surface holding a hand-written mirror of `isCompatibilityActionable` instead of calling it, which is the drift this file cannot catch. Verified: pnpm typecheck clean; pnpm test 362 passed / 27 files.
Slices 6 and 7 are done. Task 14 (publisher/harness landing docs) is the one implementation task in #33 with no code behind it, so it is recorded as not done rather than left ambiguous. The remote D1 section is new and material: remote had been missing 0009 as well as 0010, so the Phase 0 registry tables never existed in production until now. It also records that the catalog holds 130,309 skills, not the ~5k assumed earlier, which puts llms.txt under 1% coverage.
`skillx --version` printed 0.1.2 while npm published 0.4.0, because the string was hardcoded in index.ts and release-please bumps package.json only (issue #24). The version now comes from the manifest at runtime, so it cannot drift. Depth is load-bearing and the reason this helper sits at src/version.ts rather than with the other helpers in src/lib/: `../package.json` is resolved against the module's own URL, and bundling inlines the module into dist/index.js. Both src/version.ts and dist/index.js are exactly one level below the package root, so one relative path serves both; a copy under src/lib/ would be two levels deep and would resolve outside the package once bundled. Reading it can fail, so it falls back to "unknown" rather than refusing to start: a wrong version string is a small problem, a CLI that will not run is a large one. Verified at the artifact level, not just in source: pnpm build then node bin/skillx.js --version prints 0.4.0, matching the manifest, and still does when run from a different working directory. pnpm typecheck clean; pnpm test 366 passed / 28 files.
The repo had no verification on pull requests: the only workflow was release-cli.yml, on push to main. This adds a job that runs the same commands a developer runs locally — frozen install, typecheck, test, build web, build CLI — on pull requests and on main. Writing it immediately found two real defects. pnpm-lock.yaml had drifted from the manifests: apps/web's `@skillx/contracts` dependency had no entry, and packages/contracts' typescript devDependency was never recorded. `pnpm install --frozen-lockfile` would have failed in CI, so the frozen install stays as the first real gate. The root `typecheck` script filtered to apps/web, so packages/cli and packages/contracts were never typechecked anywhere. It is now recursive, and packages/cli gained the `typecheck` script it was missing. A build gate is included because a bundling-only failure is invisible to typecheck and tests: the CLI version fix in the previous commit is exactly that class of bug, and only running the built artifact catches it. Actions are pinned to commit SHAs rather than tags, the job takes `contents: read`, and checkout does not persist credentials. Verified: workflow parses, and the full local chain (frozen install, typecheck over 3 packages, 366 tests, both builds) passes. The workflow's first run on GitHub is not yet observed.
The catalog holds 130,309 skills, so llms.txt covered under 1% and llms-full.txt
about 0.15% with no way to ask for more. Both now accept `?limit=`, bounded by a
cap (5000 for the index, 1000 for the document that carries SKILL.md bodies), and
the truncation note names the knob instead of only pointing at the search API.
Two correctness fixes came out of writing it.
`Number` is used rather than `parseInt`, because `parseInt("1e9")` is 1: a caller
asking for far more than the cap would have silently received less than the
default. Clamping honours the requested magnitude. A test caught this.
The KV cache key now carries the origin. The rendered document contains absolute
URLs, so a single key shared across hosts would serve links to a different host —
which is reachable today via any preview or alternate hostname. The key also
carries the resolved limit, since that changes the body.
Unusable input falls back to the default rather than returning 400: a malformed
query parameter should not turn a readable document into an error. Also removes
an unused `siteUrl` parameter from the shared header helper.
Verified: pnpm typecheck clean over 3 packages; pnpm test 376 passed / 29 files.
The executor and the compatibility filter had unit tests, but the route itself — argument validation, the limit clamp, and the response envelope that the CLI, the search page, and the WebMCP tools all consume — was only covered indirectly. A contract detail is exactly what drifts silently. 19 tests over real HTTP bodies: a missing or non-string query is 400; a non-string `compatible` is rejected rather than coerced; the default limit is 20 and a request for 5000 is clamped to 100; zero, negative, and non-numeric limits fall back to the default; `compatible` is parsed as runtime-only so a version suffix never reaches the filter; the compatibility report and the empty-result note travel only when a filter ran; a failure is a 500 envelope rather than an empty success; and GET handles is_paid, limit, and compatible the same way POST does. executeSearch is stubbed, so these assert what the route forwards and returns, not ranking quality.
Task 14 of #33, the last implementation task in the issue. Two pages, added to the docs sub-nav (which now wraps, since five tabs overflow a narrow viewport). /docs/publishers covers the publish command and the two things publishers get wrong: ownership is verified by a GitHub collaborator check, and a listing can reach `declared` but never `verified`, because a mutable listing has no artifact digest to bind evidence to. It also states the package kinds and release states, and that only `published` resolves. /docs/harness covers what a runtime integrator needs: the Markdown surfaces and `?limit=`, the remote MCP endpoint with its five tools and the reason a tool failure is an `isError` result rather than a transport error, and the status vocabulary — with the warning that treating `unknown` as compatible is the commonest way an integration reports support it never verified. Both pages document only what exists, and say so where a capability is still ahead of the code: immutable releases and named versions are described as arriving with the registry work rather than as available today. Verified: pnpm typecheck clean; pnpm build emits both routes; 161 and 167 lines.
The parity suite can compare contracts, the web adapter, MCP, and Markdown, but it cannot import the CLI: apps/web/tsconfig.cloudflare.json lists its own files, and packages/cli/tsconfig.json sets `rootDir: ./src` while tsup does not resolve a cross-package path. Making the CLI call `isCompatibilityActionable` would mean changing how the published artifact is bundled, which is a poor trade for a one-line predicate that both sides already pin by value. The residual gap was narrow but real: adding a sixth status to COMPATIBILITY_STATUSES would have left the CLI's mirror silently wrong with no test failing. This pins the list so that change becomes a failing test pointing at the mirror. Verified: pnpm test passes with the new assertion.
This was referenced Sep 22, 2026
Adds what was done after the slice-7 review and, more usefully, why one item was refused. The CLI could stop mirroring the actionability rule by importing the contract, but that means changing rootDir and the tsup externals on the npm publish path, which release-please triggers automatically on merge. That is a poor trade for a one-line predicate, so the vocabulary is pinned at the source of truth instead and the refactor is tracked as issue #48. Also records that pull requests had no verification at all until now, and the two defects that adding it exposed: a drifted lockfile and a root typecheck script that silently covered only apps/web.
Open
6 tasks
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Phase 1 of the marketplace roadmap (#33): agent-native discovery and compatibility. A listing can declare which agent runtimes it supports, every surface resolves that declaration through one shared engine, the declaration never claims more than it can prove, and an agent can read the catalog as Markdown, over MCP, in-page over WebMCP, or through two new landing docs — without scraping HTML.
All 15 implementation tasks of the issue are done, plus four hardening items found while finishing them.
The problem this solves first
skillshad no compatibility column and nothing linked a listing to apackage_releasesrow, so a release-only surface would have answeredunknownfor the entire catalog. Rather than ship a permanent no-op, this addsskills.compatibility_jsonas a transitional second declaration tier with a single resolver owner, documented as such. Phase 2 deletes that tier when listings can be linked to immutable releases.The one rule
selectDeclarationinpackages/contractsowns precedence, and web, CLI, MCP, and Markdown all delegate to it:verified.declared, neververified, because a mutable listing has no artifact digest to bind evidence to.unknown.A release declaration arriving without
hasReleaseis ignored.unknownis never actionable: onlydeclaredandverifiedcount.Slices
skills.compatibility_jsonc1a6479,95a169e— 16 tests?compatible=, search + detail)d02a897— 40 testsinspect,check,--compatible, JSON/exit-code envelope033f2d2— 32 testsaec3d1b— 5 testsllms.txt,llms-full.txt,rel=alternate, Copyc6aa7b9,f31c9ed— 71 tests89b5e6f,b09f539— 52 testsf0ee904— 15 tests8c3cd8caf62ab3,562de21,d62fccd,api.searchtests,335e4b5Hardening found while finishing the issue
The CLI reported the wrong version (#24).
src/index.tshardcoded0.1.2while npm published0.4.0. Because merge tomaintriggersrelease-please, the next publish would have shipped a binary announcing the wrong version. It now reads the manifest at runtime, and depth is load-bearing: the helper sits atsrc/version.tsbeside the entry point rather than insrc/lib/, because bundling inlines it intodist/index.jsand both locations must be exactly one level below the package root. Verified by running the built CLI, not only in source.Pull requests had no verification at all — the only workflow was
release-cli.ymlon push tomain..github/workflows/ci.ymlnow runs a frozen install, typecheck, tests, and both builds. Writing it found two real defects:pnpm-lock.yamlhad drifted from the manifests (missing the@skillx/contractsentry forapps/web), so--frozen-lockfilewould have failed; and the roottypecheckscript filtered toapps/web, sopackages/cliandpackages/contractswere never typechecked anywhere. Both are fixed.llms.txtandllms-full.txttake?limit=(caps 5000 and 1000) and name the knob when truncating. Two fixes came with it:NumberreplacesparseIntbecauseparseInt("1e9")is 1 — a caller asking for more would have received less than the default — and the KV cache key now carries the origin, since the rendered document holds absolute URLs that were previously shared across hosts.api.search.tsgained 19 route-level tests for the validation, the limit clamp, and the envelope the CLI, the search page, and the MCP tools all consume.Things worth a reviewer's attention
The Markdown surfaces run through the resolver, not around it.
renderSkillMarkdowntakespayloadGrantedas a required input rather than inferring it from whethercontentis a string — an inference would make a deliberate denial indistinguishable from a forgotten field. A withheld body renders as an explicit note, never a blank section that would read as an empty skill. The three Markdown routes read rows through one gated module (lib/catalog/public-catalog-index.ts), which the Phase 0 boundary guard covers under its "must actually call the gate" invariant.llms.txtreports the catalog total from acount(*)query, not the size of its own slice. A truncated document that reports its own length tells an agent the catalog ends there.Compatibility filtering cannot use the database.
compatibility_jsonis a JSON column that FTS5 and Vectorize cannot index, so the search executor over-fetches, filters, truncates, and reports{ evaluated, matched, undecided, refused, poolExhausted }plus anemptyFilterNote. Undecidable rows are never silently dropped.MCP tool failures are JSON-RPC successes carrying
isError, per MCP; only a malformed request or unknown method is a JSON-RPC error. A tool that cannot answer returnsfound: falsewith a reason code instead of throwing, becauseno_packageis the expected answer for most of the catalog until the registry is populated, and "not in the registry" must be distinguishable from "the call failed".get_kitreports a private collection asnot_found, identical to an unknown slug, so the tool cannot enumerate private slugs. The endpoint sets no CORS headers on purpose: MCP clients are not browsers and the WebMCP layer runs same-origin.--compatible <runtime>is runtime-only per #33. Version-aware answers live on the detail surface (?target=runtime@version), whichinspectandcheckuse.Three files hit the 200-line ceiling and were split:
skill-detail.tsx,lib/mcp/handlers.ts, and the contracts test that gained the vocabulary pin.Verification
pnpm typecheck— clean, now recursive overapps/web,packages/contracts, andpackages/cli.pnpm test— 396 passed / 30 files.pnpm build(web) — ok; the Markdown routes,/api/mcp, and both new docs pages emit as expected.pnpm build(CLI) — ok, and the built binary was executed to confirm it reports0.4.0from any working directory.Remote migration — applied
Migrations 0009 and 0010 are applied to remote
skillx-dband verified independently through the D1 REST API. Remote was missing 0009 as well as 0010, so the Phase 0 registry tables did not exist in production until now.Post-state: 28 tables (was 21),
skills130,309 rows unchanged,skills_ftsintact, 11 migrations recorded, none pending, and all seven new tables empty as expected for additive changes.Recovery points: time-travel bookmark
000049e3-00000000-000050ee-38c209ac471ef825f224801bdbf967b3, plus a schema capture at.wrangler/backups/skillx-remote-schema-PRE-0010-20260922-074943.json.What this does not prove
Stated so its absence is not mistaken for coverage:
rootDir/bundling reasons in refactor(cli): call isCompatibilityActionable instead of mirroring the rule #48. The CLI's rule is pinned by value instead, and the status vocabulary is now pinned at the source of truth so a new status fails a test.rel="alternate"hoisting, the clipboard write, or WebMCP against a real browser implementation — the WebMCP API is a draft and is covered against a fakenavigator.modelContext.?limit=the defaultllms.txtcovers under 1% andllms-full.txtabout 0.15%.@skillx/contractshas no own compile step and is typechecked through its own tsconfig plus theapps/webbuild.Out of scope
AgentKit's Go installer port, any AgentKit→SkillX dependency, commerce/payment runtime changes, and Phase 2–6 implementation (tracked as #34–#38). No new microservice — modular monolith under
apps/web/app/lib/andpackages/.Related