Skip to content

feat: paperclip-plugin-llm-wiki v0.1#1

Merged
praneybehl merged 35 commits into
mainfrom
paperclip-plugin-spec
May 29, 2026
Merged

feat: paperclip-plugin-llm-wiki v0.1#1
praneybehl merged 35 commits into
mainfrom
paperclip-plugin-spec

Conversation

@praneybehl

Copy link
Copy Markdown
Owner

Summary

Adds a new sub-deliverable: paperclip-plugin-llm-wiki, a Paperclip plugin that surfaces the LLM Wiki inside Paperclip's UI as a read-only context lens. Source lives at integrations/paperclip/plugin/; ships to npm at v0.0.1 once smoke-tested. The skill at skills/llm-wiki/ is unchanged — the plugin only reads what the skill writes.

Five surfaces, all read-only:

Surface What it does
Wiki sidebar Browse the wiki by type, drill into pages, search across the whole wiki
Full-page view (/companies/:c/plugins/llm-wiki) Same browser at full width
Issue context tab Top wiki pages relevant to the open issue, ranked by BM25 over title + description
Dashboard health widget Page count, lint status, link density, scaling-threshold messages — auto-refreshes on lint_check_interval_minutes
wiki.query agent tool BM25 search via tool call, for adapters that don't run the skill directly

What's in the box

Phase What
0 FEASIBILITY.md — validation against the live paperclipai/paperclip SDK; documents 14 SPEC errata caught before any code shipped
1 Hand-built package skeleton (the create-paperclip-plugin scaffold is unusable outside the Paperclip monorepo)
2 TS ports of wiki_search.py, wiki_lint.py, wiki_stats.py — byte-for-byte parity, mechanically tested via Python snapshot
3 Manifest with all FEASIBILITY corrections (e.g. categories plural, no sdkVersion, exact-pinned SDK calver)
4 Worker — six data providers + the wiki.query tool; graceful handling of capability denial; no writes, no event subscriptions
5 UI bundle — four slot components + shared WikiBrowser + custom ErrorBoundary (the SDK doesn't re-export one)
6 Comprehensive user docs (455-line plugin README, 170-line operator README, root README integration section, CHANGELOG)
7 scripts/prepublish-checks.mjs — 13-section gate with prepublishOnly safety net; CI workflow consolidates to one step

Plus five maintainer-flagged fixes after the initial implementation:

  • P1 Symlink containment via realpathSync (recursive walkers + wiki root + direct loadIndex reads)
  • P2 search_top_k config plumbed end-to-end (hardcoded values dropped from UI)
  • P2 lint_check_interval_minutes plumbed end-to-end (refresh() on configured interval)
  • P2 Worker short-circuits empty-query / empty-slug requests before any RPC or FS access
  • P3 README / FEASIBILITY corrections (Issue #2276 framing, schema-drift overclaim)

Plus a follow-up cross-platform cleanup fix (unlinkSync for symlink removal, try/finally so assertions gate the test result) so the security tests pass on Linux as well as macOS.

Stats

  • 49 files, +11,269 lines vs. main
  • 17 commits, atomic per concern, each commit message links the diagnosis to the fix
  • 204/204 tests green: 92 lib parity + 30 manifest + 22 worker + 32 UI + 15 security symlink + 1 smoke + 12 worker config plumbing
  • 54/54 prepublish checks pass on the full pipeline (typecheck, tests, clean build, manifest validator acceptance, slot/capability gating, no inlined React, no credential patterns in dist/, tarball contents complete with no surprises, doc cross-refs, CHANGELOG entry)

Test plan

Automated (run by CI):

  • pnpm typecheck — strict TS + bundler resolution
  • pnpm test — 204/204 across vitest node + jsdom environments
  • pnpm run build — produces dist/manifest.js, dist/worker.js, dist/ui/index.js
  • pnpm run prepublish:check — full pipeline gate (also wired as prepublishOnly so pnpm publish aborts on failure)
  • CI workflow at .github/workflows/paperclip-plugin.yml runs on PR + push to main, gated to changes under integrations/paperclip/**

Manual (still required before npm publish):

  • Smoke install against a real Paperclip instance with a populated wiki:
    • Sidebar opens, index renders, search results match python skills/llm-wiki/scripts/wiki_search.py "<query>" from the same wiki on disk
    • Open an issue → "Wiki context" tab populates with relevant pages
    • Dashboard health widget shows real counts and ticks on the configured interval
    • From an agent in that Company, call wiki.query and confirm structured data.results come back
    • Uninstall → wiki files unchanged on disk (read-only contract)

Once the smoke-test passes, the next steps are pnpm publish from integrations/paperclip/plugin/, listing PR against gsxdsm/awesome-paperclip, and a discussion on paperclipai/paperclip Discussions.

Documentation

praneybehl added 30 commits May 5, 2026 00:10
SPEC.md is the proposal for paperclip-plugin-llm-wiki — a Paperclip
plugin that surfaces the LLM Wiki inside Paperclip's UI as a read-only
context lens. Targets v0.1, will live at integrations/paperclip/plugin/
once implemented. Also gitignore .leankg/ and .remember/ tool caches.
Validates the v0.1 plugin design against the live paperclipai/paperclip
SDK on master. Verdict: GO. Workers have raw node:fs access via the
file-browser-example pattern (ctx.projects.listWorkspaces -> path.resolve
+ containment guard -> fs.readFileSync).

Documents 14 SPEC corrections that must land in Phase 1 — most notably:
manifest field is `categories` (plural array) not `category`; sdkVersion
isn't validated and shouldn't be declared; tool result shape is
{content?, data?, error?} not {content, structured}; ErrorBoundary
isn't re-exported from @paperclipai/plugin-sdk/ui (roll our own); SDK
uses calver (2026.428.0) not semver. Also flags issue #2276 (open
validator bug affecting dashboardWidget plugins) as a smoke-test risk
to monitor in Phase 7.

Per the approved plan, Phase 0 is a discrete deliverable. Stop here for
review before Phase 1.
Phase 1 of the paperclip-plugin-llm-wiki implementation, per the
approved plan and the Phase 0 feasibility report.

Skipped the @paperclipai/create-paperclip-plugin scaffold — outside the
Paperclip monorepo it tries to pnpm-pack a local SDK checkout and
silently fails when none exists. Hand-built the package using
plugin-authoring-smoke-example as the canonical reference instead.

Highlights:
- package.json: name paperclip-plugin-llm-wiki, version 0.0.1,
  files: ["dist","package.json",...], paperclipPlugin entries pointing
  at ./dist/{manifest.js,worker.js,ui/}, peerDeps pin SDK 2026.428.0
  exactly (calver — not a range).
- TypeScript strict + bundler resolution, JSX react-jsx.
- Vitest test runner, single _smoke.spec.ts asserting createTestHarness
  imports and constructs from a minimal manifest. Manifest uses
  apiVersion: 1, categories: ["workspace"] (plural array, no
  sdkVersion) per FEASIBILITY findings.
- GitHub Actions workflow runs typecheck + test on changes under
  integrations/paperclip/**; pnpm with frozen lockfile. `pnpm run build`
  will be added to CI in Phase 5 once esbuild config lands.
- Stub READMEs for the integrations/ tree and the plugin package itself
  so links resolve; full content lands in Phase 6.
- SPEC.md moved to its final home at integrations/paperclip/SPEC.md.

Phase 0 verified: pnpm install resolves deps cleanly, typecheck green,
the smoke test passes locally.
…t (Phase 2)

TDD per the plan: tests-first per module, then implementation. All four
modules under src/lib/ are byte-for-byte parity ports of the canonical
Python scripts in skills/llm-wiki/scripts/. The plugin worker (Phase 4)
will import these directly — no shelling out, no Python at runtime.

Modules:

- frontmatter.ts (port of wiki_lint.py:46-73 + wiki_search.py tokenizer):
  parseFrontmatter, extractWikilinks, tokenize. Lightweight YAML-ish
  parser, intentionally not pulling in a YAML library so quirks match
  Python exactly (key regex /^[a-zA-Z_]+:/ — no hyphens or digits in
  keys, lines that don't match are silently dropped).

- bm25.ts (port of wiki_search.py): collectPages, searchPages, backlinks,
  topLinked. Same constants (k1=1.5, b=0.75), same IDF formula, same skip
  rules. Filtering happens before index construction (so IDF reflects the
  filtered set, not the full corpus). Page collection is sorted by
  relPath so behavior is portable across filesystems — Python's
  Path.rglob iteration order is unspecified, which the snapshot generator
  accounts for by recomputing backlinks/top-linked over a sorted page
  list.

- lint.ts (port of wiki_lint.py): lintWiki returns LintFindings with
  orphans, brokenLinks, oversizedHard/Soft, missingFrontmatter,
  malformedFrontmatter, duplicateSlugs, stalePages, readErrors. Skip
  rules add README.md beyond search's set, matching Python.
  --suggest-pages capitalized-phrase mining is intentionally deferred to
  v0.2.

- stats.ts (port of wiki_stats.py): computeStats returns counts, page
  groupings, link density, largest pages, most-linked hubs, plus the
  scaling-message chain (the if/elif sequence that emits "below first
  threshold", "below shard threshold", "AT SHARD THRESHOLD", "past 300",
  "past 500" depending on totalPages, indexLines, and whether indexes/
  exists). Skip rules deliberately differ from search/lint — index.md
  is read for indexLines and excluded from page stats.

Parity is mechanically enforced by tests/fixtures/_gen_bm25_expectations.py:
it runs wiki_search.py against tests/fixtures/wiki/ and snapshots the
ranked slugs into tests/fixtures/bm25-expectations.json. The TS BM25
test loads that snapshot and asserts identical rank order across 10
queries + 6 filter cases + 4 backlinks + topLinked. Regenerate via
`python3 integrations/paperclip/plugin/tests/fixtures/_gen_bm25_expectations.py`
when fixtures or query coverage change.

Test coverage: 92/92 passing. Fixture wiki has 7 pages spanning
source/entity/concept/synthesis types so BM25 ranking is non-trivial.
Lint and stats fixtures are built dynamically via mkdtempSync to avoid
checking in long dummy files.
…ase 3)

TDD per the plan: tests first, then implementation. The manifest types
against PaperclipPluginManifestV1 and is accepted by the SDK's
validator (verified end-to-end via createTestHarness in the test).

All 14 SPEC errata from Phase 0's FEASIBILITY.md are applied:

- categories: ["workspace"] (plural array, not category: "workspace")
- No sdkVersion field — the validator's Zod schema does not check it
- entrypoints: { worker, ui } (required object, not just package.json's
  paperclipPlugin field)
- Tool descriptor includes parametersSchema in the manifest entry
  (worker re-uses the same shape via Pick<...>)
- Capability list (8 items): four ui.*.register, agent.tools.register,
  projects.read + project.workspaces.read (the FS gate from
  plugin-file-browser-example), issues.read. events.subscribe dropped
  for v0.1 — we don't subscribe to events
- No write capabilities — the plugin is strictly read-only
- No http.outbound — no external network calls

Tests cover 30 contract points: identity (id format, apiVersion=1, semver,
length caps); slot↔capability gating via the verbatim
UI_SLOT_CAPABILITIES table from FEASIBILITY §3; tool shape and required
fields; instanceConfigSchema defaults and bounds; exportName alignment
with the named exports the UI bundle will ship in Phase 5;
read-only / no-write enforcement; and final harness validation.
TDD per the plan. Worker registers six data providers consumed by the
UI bundle (Phase 5) and one agent-callable tool — all read-only, no
event subscriptions in v0.1.

Filesystem pattern lifted verbatim from plugin-file-browser-example
(FEASIBILITY §1):

  1. ctx.projects.getPrimaryWorkspace(projectId, companyId) → workspace.path
  2. path.resolve(workspace.path, config.wiki_path) → root
  3. containment guard: !path.relative(workspace.path, root).startsWith("..")
  4. node:fs.readFileSync / readdirSync directly

Data providers:

  - readPage({ companyId, projectId, slug })
      → { slug, meta, body, links } | { error }
      Path-traversal guard: slugs that resolve outside the wiki root
      return error, never read.
  - searchWiki({ companyId, projectId, query, topK, filters })
      → { results: [{ slug, title, type, score }] }
      Delegates to lib/bm25.ts unchanged.
  - loadIndex({ companyId, projectId })
      → { index, shards, pages }
      Reads index.md plus indexes/*.md shards if present, plus a flat
      page list as a fallback.
  - lintWiki({ companyId, projectId }) → LintFindings (lib/lint shape)
  - wikiHealth({ companyId, projectId })
      → { pageCount, indexLines, linkDensity, scalingMessages,
          lintStatus: pass|warn|fail, lintFindings, wikiPathMissing }
      Hard-fail conditions (oversize hard, malformed FM, dup slugs)
      yield "fail"; soft conditions yield "warn"; clean yields "pass".
  - relevantForIssue({ companyId, issueId, topK })
      → BM25 over issue.title + issue.description, scoped via
      ctx.projects.getWorkspaceForIssue with a Company-level fallback.

Tool:

  - wiki.query (capability: agent.tools.register)
      Returns ToolResult { content, data, error } per FEASIBILITY §4
      (NOT { content, structured } as SPEC said — corrected here).
      content is a markdown summary; data.results is the structured rank.

All host-RPC calls are wrapped so the worker returns graceful
{ error: ... } responses instead of propagating Errors. Capability
denial test exercises this against a manifest stripped of
project.workspaces.read; the harness's stub throws but the worker
catches and surfaces an error string.

onHealth() returns { status: "ok" } unconditionally — wiki misconfig
surfaces via the wikiHealth provider, not via the liveness probe.

Test coverage: 18 new worker tests (14 happy-path, 2 traversal/denial,
2 onHealth/no-actions). Total project test count: 140/140 green;
typecheck clean.
…(Phase 5)

TDD per the plan: tests-first per component using @testing-library/react +
jsdom (opt-in via per-spec environment directive); SDK ui hooks mocked
through vi.mock so tests stay independent of bridge wiring.

Components:

  - ErrorBoundary           Class boundary; rolled our own because
                            @paperclipai/plugin-sdk/ui doesn't re-export
                            it from index.ts (FEASIBILITY §4 / §8 #4).
                            Wraps every top-level slot.
  - WikiPageView            Markdown renderer with remark-gfm. Wikilinks
                            ([[slug]] / [[slug|display]]) are expanded to
                            standard markdown links with a `wiki:`
                            sentinel scheme; a custom <a> renderer turns
                            them into internal links with data-wiki-slug.
                            Custom urlTransform preserves the sentinel
                            (react-markdown's default sanitizer drops
                            unknown schemes).
  - WikiBrowser             Shared "browser" surface used by Sidebar and
                            Page. Search input dispatches searchWiki on
                            change; list of pages comes from loadIndex;
                            clicking either drills into readPage and
                            renders WikiPageView. Single state machine,
                            no router.
  - WikiSidebar             Thin wrapper over WikiBrowser with a sidebar
                            class hook. Type: PluginSidebarProps.
  - WikiPage                Thin wrapper over WikiBrowser at full width.
                            Type: PluginPageProps.
  - WikiContextTab          Issue-detail tab. Reads companyId + entityId
                            from the slot context (PluginDetailTabProps
                            guarantees both are non-null for this slot)
                            and dispatches relevantForIssue.
  - WikiHealthIndicator     Dashboard widget. Renders pageCount, lint
                            status badge (data-lint-status pass|warn|
                            fail), link density, and the scaling-message
                            chain from worker's wikiHealth payload.
                            Special "wiki not configured" state when
                            wikiPathMissing is true.

src/ui/index.tsx exposes the named exports the manifest's slot
exportName fields point at (WikiSidebar, WikiPage, WikiContextTab,
WikiHealthIndicator) plus WikiPageView and ErrorBoundary as utilities.

Build: esbuild.config.mjs uses createPluginBundlerPresets from
@paperclipai/plugin-sdk/bundlers (NOT a hand-rolled config —
FEASIBILITY §4). Externals are set by the preset:

  worker: ["react", "react-dom"]   (worker bundles the SDK)
  ui:     ["@paperclipai/plugin-sdk/ui",
           "@paperclipai/plugin-sdk/ui/hooks",
           "react", "react-dom", "react/jsx-runtime"]

`pnpm run build` produces dist/manifest.js, dist/worker.js,
dist/ui/index.js — exactly the paths package.json#paperclipPlugin
references.

CI now runs `pnpm run build` after typecheck/test, then verifies that
`npm pack --dry-run` actually lists the dist/ entries (the most-cited
publishing failure per FEASIBILITY §7 / SPEC §"Build and release").

Test coverage: 169/169 green (29 new UI tests). Vitest config enables
globals so @testing-library/react@16 auto-cleans the jsdom container
between renders.
Four doc updates that ship with the Paperclip integration:

- integrations/paperclip/plugin/README.md — npm-facing surface. Explains
  the five UI surfaces, the eight capabilities the plugin requests with
  per-capability rationale, the three instanceConfigSchema fields with
  defaults and bounds, the pairing with the agent-side skill, and
  troubleshooting (including the upstream issue #2276 workaround).

- integrations/paperclip/README.md — operator-facing. Two-tier
  integration model: skill is always required, plugin is optional but
  high-value if you curate the wiki, want issue-relevant context inline,
  want a dashboard health card, or run HTTP-only adapters that need
  wiki.query. Heartbeat pattern unchanged. Cross-links to SPEC,
  FEASIBILITY, the npm-package readme, and the agent-memory stanza.

- README.md (root) — new "Integrations" section between "Tooling" and
  the existing top-level content. Short paragraph explaining the
  human-side plugin and pointing at the three integrations/paperclip/
  doc files.

- CHANGELOG.md — entry under [Unreleased] covering the new plugin
  package, algorithmic-parity testing against the Python reference, the
  SPEC + FEASIBILITY documents, the dedicated CI job, and notes on
  Paperclip SDK calver pinning + the two upstream issues we work around.
scripts/prepublish-checks.mjs walks every machine-checkable contract
the publish tarball needs to satisfy, in 13 sections:

  1.  TypeScript typecheck
  2.  Full test suite (lib parity + worker harness + UI rendering)
  3.  Clean rebuild from rm -rf dist
  4.  Built artifacts present at expected paths
  5.  Built artifacts are syntactically valid ESM
  6.  package.json publishing contract (name, version, files,
      paperclipPlugin paths, peerDeps SDK pin, license)
  7.  Manifest accepted by the SDK validator end-to-end
  8.  Slot/tool/capability gating per FEASIBILITY §3 + Issue #2276
  9.  UI bundle exposes the named exports the manifest references
      (loaded under jsdom — react-markdown's transitives touch DOM at
      module-load time)
  10. UI bundle externalizes host-provided runtime
  11. No credential-shaped strings in dist/
  12. Publish tarball contains the right files, no surprises
  13. Documentation cross-references intact + CHANGELOG entry present

Two scripts:

  - prepublish:check     — explicit invocation. Pass --fast to skip the
                          rebuild + tests when iterating on docs/manifest.
  - prepublishOnly       — npm/pnpm runs this automatically before
                          `pnpm publish`. Safety net so a publish without
                          passing checks aborts before pushing to npm.

CI workflow consolidates to a single gate: install + prepublish:check.
The previous separate typecheck/test/build/tarball steps duplicated what
the script now covers.

Verified: 54/54 pass on the full pipeline; 51/51 in --fast mode.
Three doc files updated so a new user can install, use, and
troubleshoot the plugin without reading code.

integrations/paperclip/plugin/README.md (453 lines, was 102)

  Restructured as the comprehensive user guide. New sections:

    - What you get             — feature summary table, what it
                                 explicitly doesn't do
    - Quick start              — 3-step walkthrough with expected output
                                 (skill install → wiki bootstrap →
                                 plugin install) including the
                                 local-path HTTP install for dev
    - How to use it            — per-surface usage guide for all five
                                 surfaces, including the wiki.query
                                 tool's JSON descriptor and ToolResult
                                 shape so agent authors can call it
                                 directly
    - Configuration            — full table with bounds + per-Company
                                 isolation note
    - Pairing with the skill   — agent-memory stanza pointer
    - Security and privacy     — concrete table of every read/write
                                 boundary, path-traversal blocking,
                                 capability denial behavior
    - Versioning and upgrades  — semver vs. SDK calver, hot upgrade,
                                 wiki schema evolution
    - Troubleshooting          — 7 named issues, each with cause + fix
    - FAQ                      — 11 questions covering common decisions
    - Capability reference     — full 8-cap table with rationale,
                                 explicit list of what's NOT requested
    - Architecture (brief)     — ASCII diagram of host ↔ worker ↔ disk
    - Repo layout              — file-tree reference

integrations/paperclip/README.md

  Rewritten as the operator-facing decision page. New sections:

    - At-a-glance comparison table     — agent side vs. human side
    - Should I install the plugin?     — bulleted criteria (install if /
                                         skip if)
    - First-time setup walkthrough     — 5 numbered steps with expected
                                         output from a green-field state
                                         to first ingest
    - How they fit together            — diagram showing skill writes,
                                         plugin reads, both share disk
    - Heartbeat pattern                — explicit 5-step flow
    - Multi-Company guidance           — what happens with several
                                         Companies on one Paperclip
                                         instance, plus the HQ-Company
                                         caveat
    - When something goes wrong        — quick triage table mapping
                                         symptom → cause → fix, with
                                         deep links to the plugin
                                         README's troubleshooting
    - Cross-references                 — every adjacent doc

README.md (root)

  Integrations section expanded:

    - Surface-by-surface table       — same table the plugin README
                                       opens with, so root visitors get
                                       the value prop without clicking
                                       through
    - Read-only design note          — preempts the "where's the
                                       editor" question
    - Per-doc breadcrumb             — comprehensive guide / decision
                                       page / SPEC / FEASIBILITY all
                                       linked with one-line descriptions
                                       of what's in each

prepublish-checks.mjs still passes 51/51 (fast mode); all internal TOC
anchors verified resolved.
Workers used statSync (which follows symlinks) inside their recursive
walkers. A symlink under wiki/ could point anywhere on disk and the
plugin would read it via readPage / searchWiki / loadIndex / lintWiki /
wikiHealth / collectPages.

The path.relative() containment check the worker already had only
catches lexical "../" escapes — it does NOT detect symlinks. statSync
transparently followed them.

Fix is the standard realpathSync + prefix-check pattern, applied at
every recursive walker:

  1. lstat each entry (don't follow symlinks).
  2. realpath the entry; verify the real target stays under
     realpath(wikiRoot).
  3. If contained, follow normally; if not, skip.

Within-tree symlinks (e.g. wiki/inside-link.md → ./innocent.md) still
resolve and are read correctly. Out-of-tree symlinks
(wiki/escape.md → ../../external/secret.txt) are silently skipped by
the lib walkers, and produce { error } from the worker's readPage.

The same realpathContained() helper now lives in src/worker.ts and in
each of src/lib/{bm25,lint,stats}.ts. Inline duplication keeps the lib
modules independent of the worker (they're already imported by tests
that don't go through the worker), and the helper is small enough to
not warrant a new shared module.

Tests: new tests/security/symlinks.spec.ts builds a fixture wiki with:

  workspace/wiki/innocent.md          (real)
  workspace/wiki/inside-link.md       → innocent.md      (allowed)
  workspace/wiki/escape.md            → ../../external/secret.txt (rejected)
  workspace/wiki/escape-dir           → ../../external           (rejected)
  external/secret.txt                 (contains a canary marker)

Asserts:
  - readPage(escape) returns error and the canary marker is NOT
    present in any field of the response
  - searchWiki for the canary marker returns no results
  - loadIndex.pages omits 'escape' and anything under escape-dir
  - lintWiki / wikiHealth / collectPages count only the 2 contained
    pages (innocent + inside-link)
  - inside-link.md is fully readable (within-tree symlinks remain valid)

7 new tests; total 176/176 green; typecheck clean.

Defense in depth — the existing path.relative() containment in
resolveWikiRoot/resolveWikiRootForIssue is preserved as a first-line
syntactic check; realpath is the second line that catches the case
path.relative() can't see.
The manifest's instanceConfigSchema exposes search_top_k (default 5,
bounded [1, 20]) and both READMEs claimed it controlled the issue tab
and the search box. In practice the UI hardcoded topK: 10 (search) and
topK: 5 (issue tab) and the worker fell back to 5 in three places —
operators changing the setting saw zero effect.

Worker becomes the single source of truth for the default. New
resolveTopK(ctx, paramTopK) helper applies the precedence:

    explicit param > config.search_top_k > 5

Out-of-range values (param OR config) fall through to the next
candidate so the schema's [1, 20] bounds are always respected. The
helper is used by searchWiki, relevantForIssue, and the wiki.query
tool handler.

UI side: WikiBrowser drops topK: 10 and WikiContextTab drops topK: 5.
Both now omit the field entirely — the worker default applies. UI
override remains available for future surfaces that need it (just pass
topK explicitly).

Tests:

  - 6 new worker cases covering: config wins when param absent, param
    wins over config, out-of-range config clamped to default,
    no-config falls back to 5, parity across searchWiki /
    relevantForIssue / wiki.query.

  - 2 new UI cases asserting WikiBrowser and WikiContextTab no longer
    pass topK in their usePluginData params (verified via
    vi.mocked(usePluginData).mock.calls).

184/184 green; typecheck clean.
… refresh (P2)

The manifest's instanceConfigSchema exposed lint_check_interval_minutes
and the README claimed periodic re-checking, but the dashboard widget
loaded once and never refreshed. Operators changing the setting saw no
effect.

Worker side: wikiHealth payload now includes a lintCheckIntervalMinutes
field, sourced from config via a new resolveLintIntervalMinutes() helper
that defaults to 60, clamps below-minimum values to 5 (matching the
schema's `minimum: 5`), and falls back to 60 on non-numeric configs.
The field is included even in the wikiPathMissing fallback so the UI
can still tick over and detect when the wiki becomes available.

UI side: WikiHealthIndicator destructures `refresh` from
PluginDataResult and runs a useEffect that sets up setInterval(refresh,
intervalMinutes * 60_000). Cleanup on unmount or interval change. No
polling while loading or in error state — wait for the first valid
payload before scheduling.

Tests:

  - 5 new worker cases: default 60, respect operator config, clamp
    below-min to 5, non-numeric falls back to 60, included even in
    wikiPathMissing path.

  - 3 new UI cases (with vi.useFakeTimers): refresh fires after the
    configured interval, fires again on the next tick, clears on
    unmount, does NOT fire while loading or in error state.

192/192 green; typecheck clean.
WikiBrowser unconditionally invokes loadIndex, searchWiki, and readPage
on every slot mount. With currentSlug = null and an empty query,
readPage(slug: "") and searchWiki(query: "") both walked the entire
wiki tree before producing nothing useful. On large wikis simply
opening the sidebar caused redundant filesystem walks and host-RPC
round-trips.

Worker now short-circuits at the entry of the two affected handlers,
before any host call:

  - searchWiki: trim+empty query → { results: [] } immediately
  - readPage:   trim+empty slug  → { error: "no slug provided" }

loadIndex is unchanged — it legitimately needs the walk; that's the
user's intent. The UI is unchanged because React hooks can't be
conditional and the worker-side fix is the correct location.

Tests: 4 new cases. We can't spyOn(node:fs) directly because builtin
ESM exports are non-configurable, so we spy on
ctx.projects.getPrimaryWorkspace instead — the first thing the worker
would do on any non-short-circuit path. If the short-circuit fires,
the workspace lookup never happens.

196/196 green; typecheck clean.
Two inaccuracies the maintainer flagged in pre-merge review.

(a) plugin/README.md claimed schema-drift fallback that doesn't exist.
    The plugin had a paragraph saying it would "fall back to a permissive
    read mode and surface a warning to the operator on startup" if the
    wiki schema evolved. We have no schema-version check, no startup
    validation hook, no warning. Replaced with an accurate statement:
    plugin and skill ship from the same repo so they're always in
    lockstep — there's no runtime fallback because there's no schema
    version negotiation, full stop.

(b) Issue #2276 was framed as affecting plugins that declare
    dashboardWidget. Re-reading the live issue body and current master
    plugin-capability-validator.ts (verified 2026-05-05): the bug
    actually affects WORKER-ONLY plugins with no ui.slots[] — the
    pre-fix validator iterated UI_SLOT_CAPABILITIES even when
    manifest.ui was undefined, so worker-only plugins got rejected with
    "Missing required capabilities for declared features:
    ui.dashboardWidget.register". Current master gates the loop on
    `uiSlots.length > 0`, fixing the bug; the issue stays OPEN until
    the fix tags a release.

    Our plugin declares a full ui.slots[] AND every matching capability,
    so we never tripped this — neither in the pre-fix nor the post-fix
    validator. The "workaround" we documented was never necessary.

Touched files:

  - plugin/README.md           Drop the schema-evolution paragraph; drop
                               the "Plugin install fails with..."
                               troubleshooting section (~14 lines);
                               rewrite the FAQ entry that linked to it
                               (no broken anchor).
  - integrations/README.md     Remove the dashboardWidget triage row.
  - FEASIBILITY.md §7          Rewrite the Issue #2276 entry to
                               accurately describe the bug as a
                               worker-only-plugin issue. Add the master
                               validator snippet so future readers can
                               verify. Note the earlier framing was
                               wrong.
  - FEASIBILITY.md TL;DR       Update the open-caveat paragraph to match.
  - FEASIBILITY.md §9          Drop the Phase 7 workaround note.
  - CHANGELOG.md Notes         Update the #2276 line to reflect the
                               corrected scope.

Pre-publish checks: 54/54 pass on the full pipeline.
…ads (P1)

The previous symlink fix hardened the recursive walkers but missed two
escape vectors that a maintainer review caught:

(a) The wiki root itself can be a symlink. resolveWikiRoot only ran a
    lexical path.relative() containment check on the resolved
    `<workspace>/wiki_path`. If `<workspace>/wiki` is a symlink to
    `/etc`, the lexical check passes (`relative` is just "wiki"), and
    every realpathContained() call downstream anchors on the escape
    destination — every entry under /etc has a realpath under /etc,
    which IS the wiki root, so the walker treats /etc as the wiki.

(b) loadIndex reads `index.md` and `indexes/*.md` directly via
    fs.readFileSync. The recursive walkers don't see those reads, so
    a symlinked `wiki/index.md` (or a symlinked `wiki/indexes/foo.md`,
    or a symlinked `wiki/indexes/` directory) leaked target content
    even after the prior fix.

Fix:

  - resolveWikiRoot / resolveWikiRootForIssue now route through
    resolvedContainedRoot(workspaceRoot, wikiRoot) which:
      1. realpaths both ends
      2. asserts realpath(wikiRoot) is contained under
         realpath(workspaceRoot)
      3. returns the realpath on success so all downstream walkers
         anchor on the canonical resolved path
    Failure (escape, missing, capability error) returns null — the
    data handlers convert null to a graceful response.

  - loadIndex's index.md and shard reads now run through
    realpathContained() before fs.readFileSync. The shard dir's own
    containment is checked too, so a symlinked `indexes/` pointing
    outside the wiki yields no shards.

Tests: 8 new cases extending tests/security/symlinks.spec.ts:

  - symlinked wiki root (4 cases): readPage/searchWiki/loadIndex/
    wikiHealth all surface a graceful "not accessible" or empty
    state, never leak the canary marker.
  - direct loadIndex reads (4 cases): real index.md happy path,
    symlinked index.md rejected, symlinked indexes/foo.md shard
    excluded but real shards preserved, symlinked indexes/ directory
    yields zero shards.

204/204 green; typecheck clean; 54/54 prepublish checks pass.
The new tests in fix(P1) Fix 1b passed on macOS but failed on Linux
during cleanup. Three issues:

1. rmSync(symlink_to_directory) without {recursive,force} flags throws
   EISDIR on Linux (the Node implementation lstats the path, sees it
   resolves to a directory, then refuses to remove without recursive).
   On macOS the same call succeeds. POSIX leaves this ambiguous; the
   reliable cross-platform primitive for "remove only the link, never
   the target" is unlinkSync().

2. rmSync(symlink_to_file) is mostly safe but for consistency every
   symlink removal should go through unlinkSync.

3. Cleanup ran AFTER assertions, so a failing assertion left the
   fixture in a corrupted state and bled into downstream tests. Wrap
   each mutating test in try/finally so cleanup always runs.

Three tests touched (the new wiki-root and direct-loadIndex-read
cases from Fix 1b):

  - "does not return content from a symlinked index.md..."
  - "does not return content from symlinked indexes/foo.md shards..."
  - "does not return shards when the indexes/ directory itself is a
     symlink escape"

Each now: builds the symlink → runs the assertion in a try block →
restores the fixture in finally with unlinkSync (and rmSync only on
real directories with {recursive: true, force: true}).

Other rmSync calls in the file are unchanged because they target
either top-level mkdtempSync directories (cleanup-everything case,
{recursive,force} appropriate) or real directories with that same
flag set. The grep audit at commit time confirms every code path
that touches a symlink uses unlinkSync.

15/15 symlink tests still green on macOS; expected to pass on Linux
now per the unlinkSync POSIX contract. 204/204 total tests; 54/54
prepublish checks pass.
(a) Company-scoped data calls always returned wikiPathMissing.
    resolveWikiRoot's fallback path (when no projectId is supplied,
    e.g. on the dashboard widget) walked all projects and broke on the
    first non-null workspace. Real Paperclip's getPrimaryWorkspace
    synthesizes a workspace for every project — it falls back to
    project.codebase.effectiveLocalFolder even when no explicit
    workspace row exists (see plugin-host-services.ts on master).
    So the worker happily picked the first project's synthetic
    workspace, which doesn't have the wiki, and reported missing.

    Test fixtures patched ctx.projects.getPrimaryWorkspace directly
    and so didn't exercise this; only a real Paperclip install with
    multiple projects (where only one has the wiki) reproduces it.

    Fix: walk every project, accept only the one whose path actually
    contains the configured wiki dir (resolveWikiAt helper). Honor
    explicit projectId — if the slot context names a project that
    doesn't have the wiki, return null rather than silently drifting
    to a different project.

    Three new regression tests in tests/worker.spec.ts seed two
    projects (one with wiki, one without) and patch
    getPrimaryWorkspace to mimic Paperclip's synthetic-workspace
    behavior. They would have caught this had we tested it earlier.

(b) UI surfaces shipped no styles. Class names were declared
    (.llm-wiki-browser, .llm-wiki-result-link, .llm-wiki-type-badge,
    .llm-wiki-status-badge[data-lint-status], etc.) but no CSS backed
    them. The host doesn't ship a shared component kit, so the slots
    rendered as raw stacked text — sidebar list cramped without
    visual hierarchy, issue context tab titles run together with
    type labels (e.g., "Attention Is All You Needsource").

    Fix: src/ui/styles.ts — a baseline stylesheet (~150 lines)
    injected once into document.head on first slot mount, idempotent
    via a sentinel <style id>. Uses currentColor / inherit /
    transparent throughout so the plugin tracks the host theme
    automatically. Adds layout, type badges, lint-status pill colors,
    markdown prose styles, and an error-boundary fallback.

    Each top-level slot calls injectWikiStyles() in a useEffect on
    mount. Existing tests still pass — the injection is a no-op in
    jsdom test env where multiple components share document.head.

207/207 tests green; typecheck clean. Visually verified end-to-end
against a live Paperclip 2026.428.0 instance: dashboard widget
renders pageCount=7 / lint=warn / link density=2.6 with proper badge
styling; issue context tab renders 5 ranked wiki pages with title +
type pill side-by-side; sidebar groups pages by frontmatter type
under uppercase headings.
Adds the URL helpers and React hook that the new three-column wiki workspace
will dispatch on. Hash routing is the only feasible per-page URL scheme since
Paperclip registers a single non-wildcarded route segment per page slot.

- src/ui/href.ts — wikiHref / parseWikiLocation / useWikiLocation / navigateTo
  spans landing, page, folder (#@type), search (?q=), and setup (?view=setup).
- src/ui/recent.ts — sessionStorage-backed last-N pages list, cap=8, dedup by
  slug, robust against malformed payloads.
- 30 new tests (href grammar round-trip, hook hashchange/popstate behavior,
  recent cap/dedup/persistence). 237/237 green.

Phase A of the v0.4 plan; subsequent phases consume these helpers.
The reader now produces real navigation, not callback-driven local state:
wikilinks render as `<a href="/{prefix}/llm-wiki#{slug}">`, so middle-click,
copy-link, and the back-button all work. The URL is the source of truth.

- Add rehype-slug (stable heading ids), rehype-autolink-headings (hover
  permalinks), rehype-highlight (hljs language classes on code blocks).
- Drop the `onWikilinkClick` callback from WikiPageView; require a
  `companyPrefix` instead. Wikilinks resolve via the new wikiHref helper.
- WikiBrowser passes `context.companyPrefix` through. The browser itself is
  superseded in Phase G; this preserves it as a working surface during the
  transition.
- 4 new tests for href shape, slug encoding, heading ids, and hljs classes.
  241/241 green.

Phase B of the v0.4 plan.
Replaces the v0.3 single-column WikiBrowser at /{prefix}/llm-wiki with an
Obsidian-style three-pane layout: folder tree (derived from pages[].relPath)
on the left, URL-driven Reader in the center, Properties + Outline on the
right. Backlinks panel lands in Phase D.

- src/ui/page/FolderTree.tsx — recursive collapsible tree, auto-expands
  ancestors of currentSlug, supports a substring titleFilter.
- src/ui/page/PropertiesPanel.tsx — frontmatter as definition list,
  filters out title/body/nested-objects, joins string arrays with commas.
- src/ui/page/OutlinePanel.tsx — TOC linking to in-page heading anchors;
  includes extractHeadings() helper for the Reader to extract from rendered
  DOM (relies on rehype-slug ids landed in Phase B).
- src/ui/page/Reader.tsx — center dispatcher: landing (renders index.md),
  folder (#@type), page (renders WikiPageView + emits headings to right
  rail), search (?q=), setup placeholder for Phase H.
- src/ui/WikiPage.tsx — workspace shell wiring the columns. Keeps the
  legacy .llm-wiki-page-surface class for the existing slots.spec.tsx
  surface assertion.
- src/ui/styles.ts — three-column grid (collapses at 1100px / 720px), tree
  styles, panel typography, hljs palette, autolink-headings hover.

22 new tests across FolderTree (8), PropertiesPanel (5), OutlinePanel (3),
Reader dispatch + heading extraction (6). 263/263 green; typecheck clean.

Phase C of the v0.4 plan.
Pages whose body links to the active page surface in the workspace's right
rail. Reuses the existing collectPages walker (already symlink-hardened)
and the canonical extractWikilinks regex — no new FS surface, no new
capability declared.

- src/worker.ts — new `backlinks` data provider. Honours both [[slug]]
  and [[slug|alias]] forms (extractWikilinks already canonicalises). The
  active page is excluded from its own backlinks. Empty slug short-
  circuits before any walk.
- src/ui/page/BacklinksPanel.tsx — right-rail section using the same
  visual language as Properties + Outline; results are real anchor links
  resolving via wikiHref.
- src/ui/WikiPage.tsx — wires the panel into the workspace right rail.
- 5 worker test cases (linked/unlinked/empty/self-exclusion/aliased) and
  3 UI cases (renders/empty-state/usePluginData arg shape). 271/271 green.

Phase D of the v0.4 plan.
The sidebar slot stops trying to be a reader. Instead it offers an Open
link, a search input that submits to /{prefix}/llm-wiki?q=…, a Browse-by-
type list with #@type folder hrefs, a Recent list backed by sessionStorage,
and a condensed health badge. When the wiki is missing entirely it
collapses to a single "Set up the wiki →" CTA pointing at ?view=setup.

- src/ui/launcher/Launcher.tsx (new) — the launcher's body. Submitting
  the search form calls navigateTo so middle-click and copy-link work,
  and the URL becomes the wiki workspace's input directly.
- src/ui/WikiSidebar.tsx — slimmed down to the ErrorBoundary + Launcher.
- src/ui/page/Reader.tsx — calls recordRecent() on every successful page
  load. recordRecent dedups by slug, so revisits float to the front.
- src/ui/styles.ts — launcher layout namespaced under .llm-wiki-launcher-*.
- tests/ui/Launcher.spec.tsx (new) — 6 cases for the launcher contract.
- tests/ui/slots.spec.tsx — rewrote the WikiSidebar block to assert the
  new launcher surface, repointed the topK-not-hardcoded check at Reader
  (the new searchWiki caller), and added a per-test URL/sessionStorage
  reset to keep state from leaking across cases.

277/277 green; typecheck clean.

Phase E of the v0.4 plan.
…owser

The Issue detail tab's result links used `#wiki/{slug}` — a meaningless
hash on the issue page. Switch to `wikiHref(prefix, { kind: "page", slug })`
so clicking opens the wiki workspace at that page.

WikiBrowser.tsx is now superseded by Reader (center column) + Launcher
(sidebar), neither of which import it. Delete it.

- src/ui/WikiContextTab.tsx — result anchors now use wikiHref.
- src/ui/WikiBrowser.tsx — deleted.
- tests/ui/WikiContextTab.spec.tsx — new spec asserting the new href shape.
- tests/ui/slots.spec.tsx — comment refresh.

278/278 green; typecheck clean.

Phase G of the v0.4 plan.
Adds the missing piece between "I installed the plugin" and "my agents
actually use the wiki." The plugin's sandbox can't auto-install the
agent-side skill or modify any agent's heartbeat instructions, so this
view lays out the runbook in one place with copy-paste-ready blocks and
a live verifier.

Surfaces:
- /{prefix}/llm-wiki?view=setup — five-step walkthrough rendered by the
  Reader's setup branch:
    1. Wiki content (live: ✅ found / ❌ missing with /wiki:init guidance)
    2. wiki.query tool (live: registered + sample query timing)
    3. Per-adapter install commands (Claude Code, Codex, Cursor, Gemini
       CLI, OpenCode, Pi) with one-click copy buttons
    4. The canonical heartbeat stanza (CLAUDE.md / AGENTS.md / GEMINI.md)
    5. HTTP-only-agents system-prompt block (Hermes etc.)
- Dashboard widget: an "Open setup →" link in the OK state plus a Setup
  link in the missing-wiki state, both pointing at ?view=setup.

Worker:
- New `verifySetup` data provider — composes resolveWikiRoot +
  collectPages + searchPages into a structured payload the Setup view
  renders ✅/❌ from. No new capability; reuses the existing
  project.workspaces.read surface.
- `wiki.query` tool description rewritten to be self-instructive: tells
  the agent WHEN to call it (before answering Company-specific
  questions) instead of just describing the algorithm. Mirrored in
  manifest.ts so the toolbelt presents the same wording.

Files:
- src/ui/setup/snippets.ts — adapter install commands + canonical
  heartbeat stanza + HTTP-only system-prompt suggestion. Hand-maintained
  source of truth for now; auto-generation from the repo can land in a
  future enhancement.
- src/ui/setup/SetupView.tsx — checklist UI + ChecklistItem +
  CopyBlock primitives.
- src/ui/page/Reader.tsx — dispatch the setup view via SetupContainer.
- src/ui/WikiHealthIndicator.tsx — Open-setup link in OK state, Setup
  link in missing-wiki branch.
- src/ui/styles.ts — Setup-view styling (status-coloured step borders,
  copy block layout, health setup link).
- src/manifest.ts + src/worker.ts — synchronised tool description.
- tests/worker.spec.ts — verifySetup shape + missing-wiki branch (2 cases).
- tests/manifest.spec.ts — description includes "source of truth" /
  "before answering" trigger phrasing.
- tests/ui/SetupView.spec.tsx — 4 cases (renders steps, found state,
  missing-init guidance, copy blocks present).

285/285 green; typecheck clean.

Phase H of the v0.4 plan. Phase F (cmdk quick switcher + topbar) is the
remaining nice-to-have; landed separately.
Closes the last gap on the v0.4 plan. The page slot now has a topbar
with back/forward arrows, a clickable breadcrumb, and a ⌘K trigger that
opens a fuzzy-search modal of every wiki page.

Components:
- src/ui/page/Topbar.tsx — back/forward arrows wired to window.history,
  ⌘K trigger button, breadcrumb derived from the parsed location.
  Intermediate path segments link to their folder views (#@type) so the
  user can drill back up the tree by clicking.
- src/ui/page/QuickSwitcher.tsx — built on Vercel's `cmdk`. Pages are
  grouped by frontmatter type, fuzzy-filtered by typed query; selecting
  an item calls navigateTo and closes the dialog. Includes a
  useQuickSwitcherShortcut hook that listens for Cmd/Ctrl-K at the
  document level so the parent owns the open state cleanly.

Wiring + plumbing:
- src/ui/WikiPage.tsx — Topbar + QuickSwitcher mount above the existing
  three-column grid; the grid is now wrapped in .llm-wiki-workspace-grid
  to give the topbar its own row.
- src/ui/styles.ts — Topbar layout, cmdk dialog (modal positioning,
  overlay, item selection state), responsive grid wrapper.
- tests/_setup.ts (new) — installs no-op ResizeObserver +
  Element.prototype.scrollIntoView polyfills for jsdom; cmdk uses both
  unconditionally and jsdom 29 ships neither.
- vitest.config.ts — wires the setup file via setupFiles.

Tests: tests/ui/QuickSwitcher.spec.tsx (4 cases — closed-state, all
pages on open, query filtering, click-to-navigate-and-close) and
tests/ui/Topbar.spec.tsx (6 cases — breadcrumb shapes per location
kind, back wiring, switcher trigger).

295/295 green; typecheck clean; prepublish 51/51.

Phase F of the v0.4 plan — completes the eight-phase redesign.
Cleanup commit after a Karpathy-guided audit of the v0.4 work. All real
behavior bugs fixed; over-engineered bits trimmed; dead exports removed.

Real bugs:
- FolderTree: clicking an auto-expanded folder now collapses it on the
  first click. The previous implementation tracked a separate
  `userToggled` Map that defaulted unset entries to "open", so a folder
  that was open via `autoExpand` (ancestor of currentSlug) couldn't be
  collapsed without first being clicked twice. One Set is now the
  source of truth; useEffect unions in new autoExpand entries on
  navigation, toggle flips membership directly. +1 regression test.
- Launcher recent staleness: the sidebar launcher reads sessionStorage
  on mount, but the page slot writes recents from a different React
  tree. Without a refresh signal the launcher would never see new
  entries until Paperclip re-mounted the sidebar. recordRecent now
  dispatches a `llm-wiki:recent-updated` CustomEvent on every write;
  Launcher subscribes and re-reads. +2 tests (event dispatch in
  recent.spec, live refresh in Launcher.spec).

Bloat / over-engineering removed:
- Lifted "clear page metadata when leaving page view" out of Reader and
  into the workspace shell. Removes the `useNotifyClear` helper hook
  (with its eslint-disable for exhaustive-deps) and four child
  consumers' onPageLoaded(null) calls. Reader's onPageLoaded now only
  fires from PageRead — its real owner.
- PageRead: narrowed `data` once at the top instead of casting `as
  WikiPageData & { error?: string }` five times.
- WikiPageView: replaced the `as unknown as never` rehype plugin cast
  with a clean type derived from ReactMarkdown's prop type.
- Removed unused `HEARTBEAT_STANZA_SHORT` export from snippets.ts and
  the inconsistently-applied `notes` field on adapters.
- Stale doc comment in WikiPage referring to phases that have all
  shipped.

Drift prevention:
- The `wiki.query` tool description was duplicated in manifest.ts and
  worker.ts. Extracted to a single `WIKI_QUERY_DESCRIPTION` exported
  from manifest.ts; worker.ts imports it.

CHANGELOG: added a v0.4 entry under [Unreleased] documenting the
workspace, launcher, setup walkthrough, backlinks, verifySetup, tool
description, and quick switcher.

298/298 green; typecheck clean; prepublish 51/51.

Behaviour-preserving cleanup; no API surface changes.
…ading state

Two real bugs caught by the live Paperclip install of v0.4.

cmdk + ErrorBoundary fallback ("useInsertionEffect is not a function")
- Paperclip's plugin React shim re-exports a fixed allowlist of hooks
  to plugin bundles (slots.tsx getShimBlobUrl). That allowlist does NOT
  include useInsertionEffect, so any plugin that pulls in cmdk —
  which depends on @radix-ui/react-dialog, which uses useInsertionEffect
  — crashes at first render. ErrorBoundary catches it; the workspace
  becomes unusable ("Something went wrong rendering this section").
- Replace cmdk with an in-house ~70-line modal that uses only the hooks
  the host shim actually exposes (useState/useEffect/useCallback/useRef/
  useMemo). Same observable behaviour: ↑/↓ navigates, Enter commits,
  Esc/click-outside closes, query filters by case-insensitive substring
  on title and slug. Removes the cmdk dep + 11 transitive Radix deps
  from the plugin bundle.
- Existing QuickSwitcher tests pass unchanged (they assert behaviour,
  not implementation: filter, click-to-navigate, closed-state).
- _setup.ts polyfills (ResizeObserver, scrollIntoView) kept defensively
  but no longer load-bearing.

SetupView step 1 rendered missing-wiki body during loading
- The status badge correctly showed the loading spinner ("…") via
  `loading || !data`, but the body fell through to the missing-wiki
  copy ("No wiki yet for this Company..."). Operators saw alarming
  init guidance for a half-second on every load even when their wiki
  was fine. Add an explicit loading branch ("Verifying…") + an error
  branch.

Verified end-to-end against the live smoke instance:
- Workspace three-column layout renders without ErrorBoundary fallback.
- Topbar ⌘K + breadcrumb + back/forward visible.
- FolderTree shows concepts/entities/sources/synthesis; pages render.
- ?view=setup reports ✅ wiki found at /private/tmp/.../wiki + 7 pages,
  ✅ tool registered, sample query timing visible. All 5 setup steps
  with copy-paste-ready blocks.
- Sidebar launcher shows: LLM Wiki / Open / RECENT (live, populated by
  the page slot's recordRecent event) / BROWSE by type with counts /
  health badge.

298/298 green; typecheck clean.
Three follow-ups to round out v0.4.

1. Setup N/4 counter on the dashboard widget (#11)
   The plan called for a "Setup: N/4 steps complete" badge linking to
   ?view=setup, with a Dismiss button once all four pass. Shipped.
   The four auto-detectable signals:
     - Wiki resolved (path exists)
     - At least one page
     - Tool registered (always true once the plugin loads)
     - Lint passing
   The badge stays out of the way once dismissed, but reappears if any
   signal regresses (e.g. lint warns on a new ingest). Verified live
   on the smoke instance: "🟡 Setup: 3/4 steps complete / Open setup →"
   on a Company whose wiki lints with one warning.

2. Index page rendered through the page pipeline (the missing piece
   for users with real index.md files)
   Reader's Landing previously rendered index.md with a stripped-down
   `<ReactMarkdown remarkPlugins={[remarkGfm]}>`, so [[wikilinks]] in
   the index appeared as plain text and code blocks had no
   highlighting. Extracted the shared rendering pipeline into a new
   WikiMarkdown component (wikilink expansion + rehype-slug + rehype-
   autolink-headings + rehype-highlight + sentinel-aware urlTransform);
   both WikiPageView and Landing now consume it. The index becomes a
   first-class navigable surface — wikilinks resolve to real
   /{prefix}/llm-wiki#{slug} URLs.

3. Setup snippet drift check (#12)
   Plan #12 was a generator that derived setup snippets from canonical
   sources. Per Karpathy: lightest thing that solves the problem is a
   check, not a generator. scripts/check-setup-snippets.mjs parses the
   canonical heartbeat stanza out of skills/llm-wiki/references/agent-
   memory-integration.md and asserts byte-for-byte equality with
   HEARTBEAT_STANZA in src/ui/setup/snippets.ts. Wired into
   prepublish:check as section 14 (52/52 now, was 51/51); CI fails on
   drift in either file.

Files:
- src/ui/WikiMarkdown.tsx (new) — shared markdown component.
- src/ui/WikiPageView.tsx — slimmed to article chrome + WikiMarkdown.
- src/ui/page/Reader.tsx — Landing accepts companyPrefix and uses
  WikiMarkdown.
- src/ui/WikiHealthIndicator.tsx — SetupStatusBanner sub-component
  with the N/4 counter, dismissal, sessionStorage gate.
- src/ui/styles.ts — banner layout.
- scripts/check-setup-snippets.mjs (new).
- scripts/prepublish-checks.mjs — section 14 wires the check in.
- tests/ui/WikiMarkdown.spec.tsx (new, 4 cases).
- tests/ui/Reader.landing.spec.tsx (new, 1 case asserting wikilink
  rewriting in landing-rendered index.md).
- tests/ui/WikiHealthSetupBanner.spec.tsx (new, 5 cases).

308/308 green; typecheck clean; prepublish 52/52.
Two related fixes from a screenshot review.

The grown-input abnormality
- The workspace's left rail (FolderTree column) used .llm-wiki-search-
  input for its "Filter pages…" field. That class still carried a
  `flex: 1 1 auto` rule left over from v0.3's WikiBrowser horizontal
  header. In a flex-direction:column container, that made the input
  grow to fill the entire column height — a giant empty box with the
  placeholder near the bottom and the folder tree squeezed below.
- Replace `flex: 1 1 auto` with `width: 100%` + `box-sizing: border-
  box`. The input now spans the column width without stealing vertical
  space; the FolderTree sits directly below it as intended.

Sidebar simplification
- The sidebar slot becomes one navigation link to the wiki workspace.
  Drops the search input, the Recent list, the Browse-by-type list,
  the health badge, and the wikiPathMissing setup CTA. Anything richer
  lives in the workspace itself; the sidebar exists only to make the
  wiki one click away from any Company-scoped Paperclip route.
- The dashboard widget (which already covers setup status via its
  N/4 banner and the Open setup link) is untouched.

Orphans my changes created (deleted)
- src/ui/recent.ts — sessionStorage list + RECENT_UPDATED_EVENT.
- tests/ui/recent.spec.ts.
- recordRecent call inside Reader's PageRead.
- ~75 lines of launcher-section CSS that styled the dropped Recent /
  Browse / Health / Setup-CTA blocks.

Tests: 292/292 (was 308; -16 from the dropped recent.spec + slimmed
launcher + slots WikiSidebar block). Typecheck clean.

Verified live in the smoke instance:
- Sidebar HTML is a single anchor: `<a href="/SEE/llm-wiki" class=
  "llm-wiki-launcher-link" data-testid="wiki-open">LLM Wiki ↗</a>`.
- Workspace's left-rail filter input clientHeight = 37px (not the
  grown box); folder tree below it shows 4 top-level entries.
- Setup walkthrough, Topbar, Reader page rendering, dashboard N/4
  banner all still working.
praneybehl added 5 commits May 5, 2026 20:24
The previous launcher used custom .llm-wiki-launcher* CSS that didn't
match Paperclip's other sidebar entries (Dashboard / Inbox / Issues /
Routines / Goals / etc.). The user flagged the inconsistency.

Match the host's SidebarNavItem instead:

- Borrow Paperclip's exact Tailwind utility classes for nav rows:
  `flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium`,
  `text-foreground/80 hover:bg-accent/50 hover:text-foreground` for
  the inactive state, `bg-accent text-foreground` for active. These
  classes are already compiled into the host's stylesheet (same
  classes appear in ui/src/components/SidebarNavItem.tsx) — the
  plugin doesn't ship any new CSS for the launcher.
- Inline a Lucide-style "book" SVG so the row has an icon at the
  same h-4 w-4 size as Dashboard / Inbox / etc. We can't pull in
  lucide-react (the host's plugin React shim only exposes a fixed
  hook allowlist, plus icons aren't in the bridge), but a hand-
  inlined SVG with `stroke="currentColor"` matches the visual
  weight.
- Add active-route detection — `aria-current="page"` and the active
  Tailwind classes set when the URL is on `/{prefix}/llm-wiki`.
  Listens to `popstate` so it updates after hash navigations within
  the workspace.

Drop the .llm-wiki-launcher* rules from styles.ts; the launcher no
longer carries any plugin-owned CSS.

296/296 green; typecheck clean. Verified visually in the smoke
instance: the "LLM Wiki" row sits in the same nav column as
Dashboard / Inbox / Issues with identical icon, typography, padding,
and hover/active treatment. Lights up with bg-accent when on the
workspace route.
Clicking the LLM Wiki sidebar link did a hard browser navigation,
unlike Dashboard / Inbox / etc. which use React Router's <NavLink>.
Same bug existed on every other plugin link that changed the path
or query string: Open setup, Set up the wiki, issue tab results,
topbar breadcrumb, FolderTree leaves, search/folder result lists,
backlinks panel, wikilinks rendered inside markdown.

Add a small <HostLink> component that intercepts the left-click,
calls history.pushState, and dispatches a synthetic popstate event
so React Router's history listener (and our own useWikiLocation
hook) re-render in place. Modifier-clicks (cmd/ctrl/shift/alt),
middle-clicks, target="_blank", and bare-fragment hrefs all fall
through to the browser's default so open-in-new-tab and in-page
heading anchors keep working.

Replace plain <a href> with <HostLink> in every internal-route
spot:
- Launcher (the user's complaint)
- WikiHealthIndicator (Open setup, Set up)
- WikiContextTab (issue→wiki page)
- WikiMarkdown's MarkdownAnchor (wiki: scheme branch only;
  external links keep target="_blank")
- FolderTree leaves
- Reader's FolderView + SearchView result lists
- Topbar breadcrumb
- BacklinksPanel

Tests: tests/ui/HostLink.spec.tsx (7 cases — anchor render, click
intercept, modifier-click fall-through, middle-click fall-through,
target=_blank fall-through, bare-fragment fall-through, consumer
onClick still fires). 303/303 green; typecheck clean.

Verified live: clicking LLM Wiki from /SEE/dashboard transitions
to /SEE/llm-wiki without a page reload (probe attribute on <html>
survives the click), workspace mounts, URL updates. Same behaviour
as Dashboard / Inbox.
…t omits companyPrefix

Smoke testing the onboarding flow caught a real bug: from the
dashboard the missing-wiki Setup link rendered as
"/undefined/llm-wiki?view=setup" — clicking it 404'd. The sidebar
slot's link to "/SEE/llm-wiki" worked fine on the same page, so
this is a context-shape difference between PluginSidebarProps and
PluginWidgetProps: the host surfaces companyPrefix as `undefined`
in the widget context even though the SDK type says
`string | null`.

Two layers of defense:
- src/ui/href.ts — wikiHref signature now accepts
  `string | null | undefined` and treats undefined the same as
  null (returns "#"). Callers no longer build "/undefined/..."
  paths even if the host serves an unexpected shape.
- src/ui/WikiHealthIndicator.tsx — small effectiveCompanyPrefix
  helper that falls back to the first non-"instance" path
  segment of window.location when context.companyPrefix is
  empty. The Setup links and SetupStatusBanner consume the
  resolved value.

Verified live by walking the onboarding flow with the smoke wiki
moved aside:
- Dashboard widget detects state="missing", surfaces the Setup
  link as "/SEE/llm-wiki?view=setup" (was "/undefined/...").
- Click bypasses full reload (probe attribute on <html> survives).
- Step 1 shows the /wiki:init guidance.
- After restoring the wiki and clicking "Re-run verifier", step 1
  flips to ✅ with the resolved path + page count.
- Dashboard widget transitions back to state="ok" with the
  "🟡 SETUP: 3/4 STEPS COMPLETE" banner linking back to setup.

303/303 green; typecheck clean.
The previous fix inlined an effectiveCompanyPrefix() inside
WikiHealthIndicator. That left the plugin one re-discovery away
from the same drift if Paperclip ever introduces another mount
point that omits companyPrefix from the slot context.

Move the fallback into src/ui/href.ts as `resolveCompanyPrefix()`.
WikiHealthIndicator imports it; future consumers can reuse it
without re-deriving. Documented the upstream root cause inline
on the helper:

  // Paperclip's dashboard mounts the dashboardWidget slot with
  // <PluginSlotOutlet context={{ companyId }} /> — only companyId
  // is passed (ui/src/pages/Dashboard.tsx:311), so companyPrefix
  // arrives as undefined for that slot type. Other slot types
  // (sidebar, page, detailTab) pass it correctly.

The helper prefers the context-supplied value when present; falls
back to the first non-"instance" path segment of window.location.
Returns null when neither is available.

5 new tests in tests/ui/href.spec.ts cover all branches:
- prefers context value when supplied
- falls back to URL segment when undefined
- falls back to URL segment when null
- returns null on /instance/* routes
- returns null when there's no leading segment

308/308 green; typecheck clean. Verified live in the smoke instance:
both wiki-found and wiki-missing dashboard states resolve the Setup
link to "/SEE/llm-wiki?view=setup" (was "/undefined/..." before).
@praneybehl praneybehl self-assigned this May 29, 2026
@praneybehl praneybehl merged commit 2d52db9 into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant