From 1b8400b06a2d5f2b7871eb9f1edc82816e42805b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 04:57:30 +0000 Subject: [PATCH] chore(docs): drop internal planning scaffolding from repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes development-workflow artifacts that aren't authoritative docs and go stale as the code evolves: - docs/superpowers/ (~730 KB) — brainstorming specs + implementation plans written by the superpowers skills during Block 1-7 work. The durable record lives in commit messages and PR descriptions. - docs/history/ (~60 KB) — older retrospective reviews (REVIEW, POST-SWEEP-REVIEW, UNIFICATION-PLAN) from pre-roadmap sweeps. Adds docs/superpowers/ to .gitignore so future plan output stays local without having to manually clean before each commit. No links in the tree point at either directory, so nothing else needs updating. User-facing docs (architecture, config, rest-api, mcp-tools, hooks, cli-reference, quickstart, getting-started, ACCESSIBILITY, samples/, screenshots/) are untouched. Net: -22000 LOC across 16 files; no runtime impact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 + docs/history/POST-SWEEP-REVIEW.md | 220 - docs/history/REVIEW.md | 279 -- docs/history/UNIFICATION-PLAN.md | 631 --- .../plans/2026-04-17-quality-sweep.md | 1662 ------- .../plans/2026-04-18-ui-redesign.md | 4060 ----------------- .../plans/2026-04-23-block1-critical-plan.md | 1720 ------- .../plans/2026-04-23-block2-security-plan.md | 1110 ----- .../2026-04-23-block3-resource-safety-plan.md | 2222 --------- .../2026-04-23-block4-observability-plan.md | 2853 ------------ .../plans/2026-04-23-block5-ui-polish-plan.md | 2549 ----------- .../2026-04-23-block6-testing-ci-plan.md | 1763 ------- .../2026-04-23-block7-oss-polish-plan.md | 1830 -------- .../specs/2026-04-17-quality-sweep-design.md | 347 -- .../specs/2026-04-18-ui-redesign-design.md | 539 --- ...-04-23-production-polish-roadmap-design.md | 215 - 16 files changed, 6 insertions(+), 22000 deletions(-) delete mode 100644 docs/history/POST-SWEEP-REVIEW.md delete mode 100644 docs/history/REVIEW.md delete mode 100644 docs/history/UNIFICATION-PLAN.md delete mode 100644 docs/superpowers/plans/2026-04-17-quality-sweep.md delete mode 100644 docs/superpowers/plans/2026-04-18-ui-redesign.md delete mode 100644 docs/superpowers/plans/2026-04-23-block1-critical-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block2-security-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block3-resource-safety-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block4-observability-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block5-ui-polish-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block6-testing-ci-plan.md delete mode 100644 docs/superpowers/plans/2026-04-23-block7-oss-polish-plan.md delete mode 100644 docs/superpowers/specs/2026-04-17-quality-sweep-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-ui-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-04-23-production-polish-roadmap-design.md diff --git a/.gitignore b/.gitignore index b04805f..feb5458 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,9 @@ Thumbs.db *.tmp *.bak .superpowers/ + +# Internal planning/brainstorming scaffolding. Plans and specs written +# by the superpowers skills are development-workflow artifacts, not +# public docs — they go stale the moment features merge. Commit +# messages and PR descriptions carry the durable record. +docs/superpowers/ diff --git a/docs/history/POST-SWEEP-REVIEW.md b/docs/history/POST-SWEEP-REVIEW.md deleted file mode 100644 index 55dfb94..0000000 --- a/docs/history/POST-SWEEP-REVIEW.md +++ /dev/null @@ -1,220 +0,0 @@ -# Post-Sweep Review — docsiq - -Review against HEAD after the 51-commit quality sweep. Gate check before UI refresh. - -## Summary - -- **16 findings total: 0 new P0 / 3 new P1 / 4 new P2** -- **All 9 pre-sweep P0/P1 fixes verified as present and correct.** -- No regressions introduced by the sweep itself. -- **Readiness: Ready for UI work, with 3 P1s worth addressing first.** - ---- - -## Pre-sweep fixes — verification (all ✅) - -| ID | File | Evidence | Status | -|---|---|---|---| -| P0-1 | `internal/store/store.go:306-323` | Uses `docSelect` (12-col), `scanDocRow` matches | ✅ | -| P0-2 | `internal/api/handlers.go:419-434` | `filepath.Base` + absolute-path containment check | ✅ | -| P0-3 | `internal/api/notes_handlers.go:29-35,471-513` | `MaxImportEntries=10000`, `MaxImportTotalBytes=500<<20` | ✅ | -| P0-4 | `internal/api/vector_indexes.go:65-92` | `singleflight.Do` + double-checked lock | ✅ | -| P1-1 | `internal/api/handlers.go:511-522` | `progressForJob(jobID)` + `clearProgress` on terminal | ✅ | -| P1-2 | `internal/store/store.go:38` | DSN contains `_busy_timeout=5000` | ✅ | -| P1-3 | `internal/api/notes_handlers.go:119-120` | `ErrInvalidKey → StatusBadRequest` | ✅ | -| P1-4 | `internal/notes/history.go:67-70` | `sync.OnceValue` wrapping `gitLookupFn` | ✅ | -| P1-5 | `internal/mcp/notes_tools.go:66-68` | Empty `q` → `toolError("query required")` | ✅ | - ---- - -## New findings - -### P1 — should fix - -#### NF-P1-1: REST `GET /api/projects/{project}/search` silently accepts empty query — inconsistent with MCP contract - -**File:** `internal/api/notes_handlers.go:329-350` - -REST `searchNotes` does not reject empty `q`. Passes `q=""` to `st.SearchNotes`, which returns `[]` per `store/notes.go:88-89`. HTTP response is `200 {"hits":[]}` with no error. MCP `search_notes` rejects empty `q` with a tool error. - -Asymmetric contract: same logic, different validation per transport. - -**Fix:** add `if q == "" { writeError(w, r, http.StatusBadRequest, "query required", nil); return }` before the store call. - -**Confidence: 88.** - -**Status:** fixed in 3b70cf6 - -#### NF-P1-2: Git commit author not sanitised — newline injection into commit messages - -**File:** `internal/notes/history.go:144-145` - -`buildCommitMessage` concatenates caller-supplied `author` into `Co-Authored-By:` git trailer without stripping `\n`/`\r`. Author value comes from PUT body + MCP `write_note` arg. - -Injected newline corrupts the commit log. Doesn't execute code (git trailers aren't commands) but confuses parsers of `git log --pretty`. `TODO(docsiq): P2-4` at line 143 acknowledges this but classified P2 — P1 is more appropriate because `author` is direct API input. - -**Fix:** `author = strings.Map(func(r rune) rune { if r == '\n' || r == '\r' { return -1 }; return r }, author)` before assembly. - -**Confidence: 85.** - -**Status:** fixed in 98a2d6e - -#### NF-P1-3: `docs/rest-api.md` documents `request_id` in JSON error body — code never emits it - -**File:** `docs/rest-api.md:157` - -Doc states: _"Every error response body is JSON: `{"error": "...", "request_id": "..."}`."_ `writeError` (`handlers.go:73-78`) emits only `{"error": msg}`. Request ID is only in the `X-Request-ID` response header. - -Clients parsing the JSON body per docs get missing field. Important to fix **before the UI bakes in assumptions**. - -**Fix:** either thread `RequestIDFromContext` into `writeError` and include in JSON, or correct the doc to say the field is header-only. - -**Confidence: 95.** - -**Status:** fixed in e5c8801 - ---- - -### P2 — nice to have / defer - -#### NF-P2-1: `docs/mcp-tools.md` says "Docs tools (12)" — code registers 13 - -**File:** `docs/mcp-tools.md:8` - -Summary line off by one. Body lists all 13 correctly. `get_entity_claims` is tool 13. - -#### NF-P2-2: `VectorIndexes.ForProject` uses `context.Background()` — drops caller cancellation on 60s build - -**File:** `internal/api/vector_indexes.go:75` - -```go -buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) -``` - -If HTTP client disconnects during cold-start build, build runs 60s consuming CPU + SQLite read bandwidth. Thread `ctx` parameter through `ForProject` to let cancellation propagate. - -#### NF-P2-3: MCP `write_note` silently drops `IndexNote` errors (stale FTS) - -**File:** `internal/mcp/notes_tools.go:145` - -Already TODO-marked (P2-3). Mismatches REST handler which logs WARN. Consequence: MCP write reports success but note doesn't appear in `search_notes`. - -#### NF-P2-4: `_ = st.DeleteNote(...)` in REST + MCP delete — stale FTS entries - -**Files:** `internal/api/notes_handlers.go:251`, `internal/mcp/notes_tools.go:169` - -FTS5 delete failure leaves stale entry — surfaces deleted note in search. Rare in practice (simple DELETE), but inconsistent with `IndexNote` error handling. - -**Fix:** log at WARN level. - ---- - -## File-size / separation assessment - -| File | LoC | Verdict | -|---|---|---| -| `internal/store/store.go` | 1273 | Coherent. Every method is a thin SQL wrapper. No split warranted. | -| `internal/pipeline/pipeline.go` | 992 | **Highest split candidate.** Phases 2+3 (embed + extract) have enough complexity to warrant `pipeline/embed.go` + `pipeline/extract.go`. Not blocking UI work. | -| `internal/api/handlers.go` | 586 | Upload logic (progress, bg goroutine, tmp-dir) could move to `upload.go`. Rest is coherent. Not urgent. | -| `internal/api/notes_handlers.go` | 529 | Clean single responsibility. Not a split candidate. | -| `internal/mcp/tools.go` | 422 | 13 tool registrations; splitting by domain would produce 3 small files with no shared structure. Acceptable. | - ---- - -## Test skip audit - -| Location | Assessment | -|---|---| -| `docs_integration_test.go:96,99,135` — "upload pipeline did not complete" | **Potentially masking a bug.** FakeProvider is deterministic, so pipeline should complete. If it times out consistently in CI, the skip silently passes. Recommendation: assert `waitUploadDone == "done"` in CI, convert to `t.Fatalf` once confirmed reliable. | -| `history_integration_test.go:67,94` — "fewer than 2 entries" | Legitimate. Secondary guard for git-missing envs. | -| `notes_import_limits_test.go:81`, `notes_test.go:230`, `hnsw_test.go:216` — `-short` guards | Correct use of `-short`. Slow tests, not functional logic. | -| `registry_test.go:47,50` — Windows / root chmod | Legitimate platform-conditional skips. | -| `installer_test.go:265` — symlink on Windows | Legitimate. | - ---- - -## Doc drift - -| Doc | Claim | Actual | Severity | -|---|---|---|---| -| `docs/rest-api.md:157` | Error JSON has `request_id` field | Only `{"error":"..."}` emitted | P1 (NF-P1-3) | -| `docs/mcp-tools.md:8` | "Docs tools (12)" | 13 doc tools registered | P2 (NF-P2-1) | -| `docs/config.md` | Fields match `config.go` | No drift | ✅ | -| `docs/rest-api.md` route table | All routes listed | All `router.go` routes covered | ✅ | - ---- - -## Error-swallowing assessment - -| Site | Verdict | -|---|---| -| `mcp/notes_tools.go:145` `_ = st.IndexNote` | Bug-worthy (P2-3 TODO). Fix: log WARN. | -| `mcp/notes_tools.go:169` `_ = st.DeleteNote` | Bug-worthy (NF-P2-4). Fix: log WARN. | -| `api/notes_handlers.go:251` `_ = st.DeleteNote` | Same as above for REST. | -| `api/notes_handlers.go:524` `_ = st.IndexNote` (import path) | Acceptable: best-effort; re-import retries. | -| `notes/watcher.go:71` `_ = filepath.WalkDir` | Acceptable: per-entry errors absorbed; next poll retries. | -| `api/auth.go:83` — 401 response write | Acceptable: client already disconnected. | -| `api/router.go:168` — SPA fallback write | Acceptable: headers may be sent. | -| `api/metrics.go:159` — Prometheus text write | Acceptable: scraper disconnect. | - ---- - -## Concurrency correctness - -All hotspots re-read: -- `internal/notes/history.go` per-project mutex map — safe; `perProjectLocksMu` guards all access. -- `internal/notes/watcher.go` start/stop — safe; channel-close-once pattern. -- `internal/api/stores.go` Get — safe; mutex held for full get+insert. -- `internal/sqlitevec/load.go` extension loading — safe with `MaxOpenConns=1`. - ---- - -## Context cancellation - -| Site | Assessment | -|---|---| -| `handlers.go:467` `bgCtx := context.Background()` | Intentional (background goroutine must outlive HTTP response). Correct. | -| `vector_indexes.go:75` `context.Background()` | Drops caller ctx on 60s build. **NF-P2-2.** | -| `sqlitevec/load.go:55` `context.Background()` | Startup-only; acceptable. | -| `loader/pdf.go:30` `context.Background()` | Drops pipeline ctx. Low priority. | -| All upload/search handlers | `r.Context()` threaded correctly. | - ---- - -## Security surface - -- **Bearer auth:** `/health` + `/metrics` public by design; `/api/*` + `/mcp` gated. No new bypass. -- **Path traversal:** Upload filenames, tar entries, note keys, hook installer paths — all addressed. -- **CSRF:** No state-changing GETs. Token-auth mitigates. -- **Error leakage:** `writeError` sends only human `msg` to client; internal details go to slog. One minor: `resolveStore` sends `"open project store: "+err.Error()` which can include a filesystem path in a 500 body (low severity, auth-gated). - ---- - -## API contract sanity - -- `GET /api/projects/{project}/search` accepts empty `q` (NF-P1-1 — inconsistent with MCP). -- Project middleware returns `http.Error` plain text on invalid slug instead of standard JSON error shape. Minor. -- `/health` + `/api/stats` policies correct. - ---- - -## Dependency health - -- `go.uber.org/goleak v1.3.0` — used in `itest/harness.go`. Justified. -- `golang.org/x/sync v0.20.0` — used for `singleflight` (P0-4 fix). Correct. -- Indirect deps via `langchaingo` — not directly imported. -- `go 1.25.0` in go.mod — valid (Go 1.25 released Aug 2025). - ---- - -## Readiness for UI work - -**Ready, with caveats.** - -The 9 pre-sweep P0/P1 fixes are all present and correct. No new P0 found. Three new P1s are correctness gaps, not blockers: - -1. **NF-P1-1** (REST search empty query) — UI may rely on consistent validation behavior -2. **NF-P1-2** (git author sanitization) — not UI-facing but security-adjacent -3. **NF-P1-3** (`request_id` doc drift) — **UI should NOT bake in assumptions about the doc'd error shape; fix first** - -Recommend fixing all 3 before starting UI refresh to avoid contract churn mid-UI-work. diff --git a/docs/history/REVIEW.md b/docs/history/REVIEW.md deleted file mode 100644 index 94196d7..0000000 --- a/docs/history/REVIEW.md +++ /dev/null @@ -1,279 +0,0 @@ -# Code Review — docsiq quality sweep - -## Summary -- **14 findings total: 4 P0 / 5 P1 / 5 P2** -- **Wave-E remediation: all 4 P0 and all 5 P1 findings fixed (9/9). P2s deferred per Wave-E scope.** -- Packages audited: `internal/api`, `internal/notes`, `internal/store`, `internal/vectorindex`, `internal/mcp`, `internal/hookinstaller`, `internal/sqlitevec`, `cmd` -- Lines audited: ~12,000 - ---- - -## P0 — must fix - -### [P0-1] `GetDocumentVersions` column-count mismatch causes runtime error on every call — `internal/store/store.go:304` - -**What:** `GetDocumentVersions` uses an inline `SELECT` that omits `indexed_mtime` (11 columns), but passes rows into `scanDocRow` which calls `rows.Scan` with 12 destinations. - -**Impact:** Every call to `GET /api/documents/{id}/versions` fails with `sql: expected 12 destination arguments in Scan, not 11`. The endpoint is completely broken. - -**Evidence:** -``` -store.go:304-308 — inline query, 11 cols, NO indexed_mtime: - SELECT id,path,title,doc_type,file_hash,structured,version,canonical_id,is_latest,created_at,updated_at - FROM documents WHERE id=? OR canonical_id=? ORDER BY version ASC - -store.go:331-332 — scanDocRow, 12 Scan destinations: - rows.Scan(&d.ID,&d.Path,&d.Title,&d.DocType,&d.FileHash,&d.Structured, - &d.Version,&canonicalID,&isLatest,&indexedMtime,&d.CreatedAt,&d.UpdatedAt) - -store.go:324 — docSelect is the correct 12-col query, but - GetDocumentVersions does NOT use docSelect. -``` - -**Recommended fix:** Replace the inline query with `docSelect + " WHERE id=? OR canonical_id=? ORDER BY version ASC"` to match every other doc query function. - -**Status:** fixed in 0cd9a97 - ---- - -### [P0-2] Path traversal via multipart filename in upload handler — `internal/api/handlers.go:409` - -**What:** The upload handler writes each file to `filepath.Join(tmpDir, fh.Filename)` without sanitizing `fh.Filename`. A multipart upload with `filename="../../etc/cron.d/evil"` escapes the temp directory. - -**Impact:** An authenticated attacker can write arbitrary files to any path writable by the server process, enabling privilege escalation or persistent backdoor installation. - -**Evidence:** -```go -// handlers.go:408-409 -for _, fh := range files { - dst := filepath.Join(tmpDir, fh.Filename) // no containment check - // ... - out, err := os.Create(dst) -``` -`filepath.Join("/tmp/docsiq-upload-abc", "../../etc/cron.d/pwn")` evaluates to `/etc/cron.d/pwn`. No path containment assertion follows before `os.Create(dst)`. - -**Recommended fix:** Sanitize `fh.Filename` to `filepath.Base(fh.Filename)` (stripping all directory components) before joining, AND assert `strings.HasPrefix(absDst, absTmpDir+string(os.PathSeparator))` as defense-in-depth. Reject entries whose sanitized name is empty, `.`, or `..`. - -**Status:** fixed in 42109f7 - ---- - -### [P0-3] No total decompressed-size cap in tar import enables OOM — `internal/api/notes_handlers.go:448-502` - -**What:** `importTar` enforces a per-entry cap of `MaxNoteBytes` (10 MB) but imposes no limit on the number of entries or total decompressed bytes across the archive. A crafted `.tar.gz` with thousands of near-maximum-size entries can exhaust server memory. - -**Impact:** Any authenticated user can crash the server by uploading a crafted archive (a gzip bomb or simply a very large tar), causing OOM and denial of service. - -**Evidence:** -```go -// notes_handlers.go:479-488 — per-entry limit only: -data, err := io.ReadAll(io.LimitReader(tr, MaxNoteBytes+1)) -if len(data) > MaxNoteBytes { ... return } -// No entry count limit, no running total of bytes decompressed. -// A tar with 10,000 entries × 10MB = 100GB of decompressed data. -``` - -**Recommended fix:** Add a counter for total bytes decompressed AND a maximum entry count. Suggest `MaxImportEntries = 10_000` and `MaxImportTotalBytes = 500 << 20` (500 MB). Return 413 when either limit trips. - -**Status:** fixed in 06960fc - ---- - -### [P0-4] TOCTOU race in `VectorIndexes.ForProject` — `internal/api/vector_indexes.go:35-63` - -**What:** `ForProject` checks the cache under the mutex, releases it, builds the index (a potentially 60-second operation), then re-acquires. Two concurrent goroutines for the same slug both pass the first empty-cache check and both call `vectorindex.BuildFromStore` simultaneously. The store has `MaxOpenConns=1`, so both builds compete for the single SQLite connection, blocking each other and all other requests to that project for up to 120s combined. - -**Impact:** Under concurrent first-search load for a project (common at startup with multiple simultaneous clients), API requests to that project stall for the full 60-second build window × number of racing goroutines. - -**Evidence:** -```go -// vector_indexes.go:39-44 — check-then-unlock: -v.mu.Lock() -if idx, ok := v.indexes[slug]; ok { - v.mu.Unlock() - return idx -} -v.mu.Unlock() // <-- released here; another goroutine enters the same block - -// Both call BuildFromStore concurrently: -idx, err := vectorindex.BuildFromStore(buildCtx, st) // up to 60s, uses the single DB conn -``` - -**Recommended fix:** Use `golang.org/x/sync/singleflight` keyed by slug so only one build runs for a given slug at a time; all other callers wait on the same result. Alternative: an in-cache placeholder `*Index` guarded by a sync.WaitGroup so concurrent callers block on the in-flight build. - -**Status:** fixed in 9ea058e - ---- - -## P1 — should fix - -### [P1-1] `uploadProgress` SSE stream: `jobProgress` map never pruned and multi-job tracking is broken — `internal/api/handlers.go:469-517` - -**What:** Completed job entries are never removed from `h.jobProgress`, so the map grows without bound. When multiple uploads run concurrently, the SSE poll loop picks an arbitrary job's status (Go map iteration is non-deterministic) and terminates the stream when any job reaches `"done"` — even if the caller's own job is still running. - -**Impact:** (a) Memory leak in long-running servers with many uploads. (b) Client polling `GET /api/upload/progress` with two concurrent jobs may receive premature `done` for a job that hasn't finished, or miss their own job's error events. - -**Evidence:** -```go -// handlers.go:499-514 — picks last value from unordered map iteration: -for _, v := range h.jobProgress { - msg = v // non-deterministic when len > 1 -} -if msg == "done" || strings.HasPrefix(msg, "error:") { - return // terminates for any job, not just caller's -} -// setProgress() at line 469 adds entries; nothing ever deletes them. -``` - -**Recommended fix:** Add a `?job_id=` query parameter to `uploadProgress`, filter `jobProgress` by that ID, delete the entry from the map when `"done"` or `"error:"` is emitted. Return the `job_id` from the upload response so clients can correlate. - -**Status:** fixed in aabb50c - ---- - -### [P1-2] SQLite DSN missing `_busy_timeout`; concurrent requests return immediate `SQLITE_BUSY` — `internal/store/store.go:35` - -**What:** The SQLite connection string sets WAL and foreign keys but does not set `_busy_timeout`. With `MaxOpenConns=1`, any second concurrent database operation on the same project store gets an immediate `database is locked` / `SQLITE_BUSY` error instead of retrying. - -**Impact:** Under concurrent API load (search + upload finalize), one operation fails with a 500 error that wouldn't occur with a busy timeout set. - -**Evidence:** -```go -// store.go:35 — no _busy_timeout: -db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on") -``` - -**Recommended fix:** Add `&_busy_timeout=5000` (5 seconds) to the DSN in `open()`. - -**Status:** fixed in 619243a - ---- - -### [P1-3] `ErrInvalidKey` mapped to HTTP 403 Forbidden instead of 400 Bad Request — `internal/api/notes_handlers.go:105-116` - -**What:** The `notesError` helper maps `notes.ErrInvalidKey` to `http.StatusForbidden` (403). An invalid note key (contains `..`, null byte, etc.) is a client input validation error, not an authorization failure. - -**Impact:** Clients and monitoring systems interpret 403 as "you don't have permission" rather than "your request is malformed". Client retry logic behaves incorrectly. - -**Evidence:** -```go -// notes_handlers.go:109-110: -case errors.Is(err, notes.ErrInvalidKey): - writeError(w, r, http.StatusForbidden, "invalid key: "+err.Error(), err) -``` -Same mapping appears in `writeNote` at line 172 and `deleteNote` at line 233. - -**Recommended fix:** Change `http.StatusForbidden` → `http.StatusBadRequest` (400) for `ErrInvalidKey` cases. Reserve 403 for authorization failures. - -**Status:** fixed in 619243a - ---- - -### [P1-4] `gitAvailable()` calls `exec.LookPath` on every note write — `internal/notes/history.go:56-60` - -**What:** `autoCommit` calls `gitAvailable()` on every note write or delete, which calls `exec.LookPath("git")`. This is a filesystem `stat` syscall chain executed synchronously on the hot write path. - -**Impact:** On systems where git is not on `PATH` (common in containers), this performs a full `PATH` directory scan on every write. Latency spikes in profiling. - -**Evidence:** -```go -// history.go:56-60: -func gitAvailable() bool { - _, err := exec.LookPath("git") - return err == nil -} -// Called at history.go:138 inside autoCommit, per Write() and Delete(). -``` - -**Recommended fix:** Cache the result in a `sync.Once` at package level. Git binary location doesn't change during server lifetime. - -**Status:** fixed in 619243a - ---- - -### [P1-5] `search_notes` MCP tool passes empty query without validation — `internal/mcp/notes_tools.go:61-79` - -**What:** The `search_notes` MCP tool extracts `query` and calls `st.SearchNotes(ctx, q, limit)` even when `q` is empty, relying on the store's internal short-circuit. Diverges from all other MCP tools (`search_documents`, `local_search`) which return `toolError("query required")` for empty queries. - -**Impact:** MCP client gets silent `{"hits":[]}` instead of a clear error. Inconsistent behavior across tools. - -**Evidence:** -```go -// notes_tools.go:63-65 — no guard: -q := stringArg(args, "query", "") -limit := intArg(args, "limit", 20) - -// Compare search_documents (tools.go:49-51): -if query == "" { - return toolError(fmt.Errorf("query required")), nil -} -``` - -**Recommended fix:** Add `if q == "" { return toolError(fmt.Errorf("query required")), nil }` after extracting `q`. - -**Status:** fixed in 619243a - ---- - -## P2 — nice to have / defer - -### [P2-1] `ParseMultipartForm` memory limit does not bound total upload size — `internal/api/handlers.go:384` - -**What:** `r.ParseMultipartForm(128 << 20)` buffers up to 128 MB in memory. No `http.MaxBytesReader` wraps `r.Body`, so a client can stream an arbitrarily large upload body. Files exceeding 128 MB spill to disk rather than being rejected. - -**Recommended fix:** Wrap `r.Body` with `http.MaxBytesReader(w, r.Body, maxBytes)` before `ParseMultipartForm`. Make the max configurable via `cfg.Server.MaxUploadBytes`. - -**Status:** deferred (P2), TODO planted at internal/api/handlers.go:384 - ---- - -### [P2-2] `/metrics` endpoint is unauthenticated while exposing operational details — `internal/api/router.go:84`, `internal/api/metrics.go:130` - -**What:** Prometheus `/metrics` is mounted outside the auth wrap. It exposes project names, note counts, request paths, and latency distributions to any unauthenticated caller. - -**Recommended fix:** Either document this as intentional (Prometheus convention) or add an optional separate scrape token via `cfg.Server.MetricsKey`. - -**Status:** deferred (P2), TODO planted at internal/api/router.go:84 and internal/api/metrics.go:130 - ---- - -### [P2-3] `write_note` MCP tool silently swallows `IndexNote` errors — `internal/mcp/notes_tools.go:141` - -**What:** MCP `write_note` calls `_ = st.IndexNote(ctx, n)`, discarding the error. REST handler logs `slog.WarnContext` on the same error. Inconsistent — FTS5 index failures invisible through MCP. - -**Recommended fix:** Add `slog.WarnContext(ctx, "notes FTS index failed", "key", key, "err", err)` to match the REST handler. - -**Status:** deferred (P2), TODO planted at internal/mcp/notes_tools.go:144 - ---- - -### [P2-4] `buildCommitMessage` does not sanitize author for git trailer injection — `internal/notes/history.go:119-127` - -**What:** Commit message includes `Co-Authored-By: <@local>`. `author` comes from API request body. A value containing newlines can inject additional git trailer lines. - -**Recommended fix:** Strip `\n` and `\r` from author before embedding. Alternatively, reject author values containing control chars at the handler level. - -**Status:** deferred (P2), TODO planted at internal/notes/history.go:142 - ---- - -### [P2-5] `VectorIndexes.ForProject` builds index outside the mutex without singleflight — duplicate work — `internal/api/vector_indexes.go:46-62` - -**What:** Even post-P0-4 fix, absent singleflight two goroutines racing for the first build both complete their builds and the second result is discarded. Wasted CPU/IO for a 60s build. - -**Recommended fix:** Use `golang.org/x/sync/singleflight` keyed by slug to deduplicate concurrent builds. (P0-4's fix likely subsumes this — verify.) - -**Status:** resolved by P0-4 fix in 9ea058e (singleflight.Group now coalesces concurrent first-touch builds in `ForProject`; no TODO planted). - ---- - -## What looks good - -- **`projectStores` cache** (`internal/api/stores.go`): mutex held for full get-or-open, no TOCTOU gap. `Close()` drains the map under the same lock. -- **`ValidateKey` + `resolvePath`** (`internal/notes/notes.go:51-104`): thorough traversal defense — segment-by-segment `..` check + post-`Abs` containment assertion. Defense-in-depth correctly layered. -- **`importTar` traversal rejection** (`internal/api/notes_handlers.go:457-473`): `filepath.Clean` + double containment check correctly blocks tar path traversal attacks. -- **`bearerAuthMiddleware`** (`internal/api/auth.go:64`): `crypto/subtle.ConstantTimeCompare` used correctly; never logs the submitted token; case-sensitive scheme intentional and documented. -- **`Write` atomicity** (`internal/notes/notes.go:209-231`): temp file + `Sync` + rename correctly implemented; temp cleaned on all error paths; per-project mutex serializes write + auto-commit. -- **HNSW concurrency** (`internal/vectorindex/hnsw.go`): `Add` and `Search` acquire the appropriate mutex; `rng` only accessed from `Add` holding the write lock. -- **Schema migration** (`internal/store/store.go:82-103`): idempotent `ALTER TABLE` with duplicate-column error catch handles SQLite's error message correctly. -- **Hook installer atomicity** (`internal/hookinstaller/installer.go:100-143`): temp-file + rename prevents corrupt config on crash; symlink resolution lands temp file on the same filesystem as target. diff --git a/docs/history/UNIFICATION-PLAN.md b/docs/history/UNIFICATION-PLAN.md deleted file mode 100644 index 8d14db4..0000000 --- a/docs/history/UNIFICATION-PLAN.md +++ /dev/null @@ -1,631 +0,0 @@ -# docsiq — Unification Review & Plan - -> **STATUS: Implemented.** This plan was executed 2026-04-17 across commits 3d2d2ce..HEAD on main of the docsiq repo (quality-sweep work is ongoing). Retained for historical reference. - -**Date:** 2026-04-17 -**Scope:** Deep review of `docsiq/` and `kgraph/`, assessment of feature completeness, -and a plan for combining them into a single coherent product. -**Status:** Planning only — no code changes proposed in this document. - ---- - -## 1. Executive Summary - -Two sibling repos live under `/home/dev/projects/docsiq/`: - -| Repo | Language | Role | LoC (approx) | -|---|---|---|---| -| `docsiq/` | Go 1.22 + React/Vite UI | GraphRAG **document indexer** — ingest PDFs/DOCX/MD/web, build entity graph, community summaries, serve MCP + REST + UI | ~6–8k Go + UI | -| `kgraph/` | TypeScript (Bun) + React/Vite UI | Per-project **AI-session memory** — markdown notes with `[[wikilinks]]`, graph UI, MCP tools, SessionStart hook | 4,134 | - -They solve **two halves of the same problem**: docsiq turns *source documents* into a -queryable knowledge graph; kgraph captures *agent-authored notes and decisions* during coding -sessions into a linked graph. Today they duplicate ~40% of surface area (MCP server, REST API, -graph UI, SQLite store, project config, hooks-adjacent concerns) but with incompatible schemas, -runtimes, and storage layouts. A merger unlocks the "read your docs **and** remember what the -team decided" experience neither delivers alone. - -**Constraints (locked):** -- Single programming language — **Go**. -- Distribution — **`go install github.com//docsiq@latest`**. No prebuilt binaries shipped. -- **CGO enabled.** Unlocks `mattn/go-sqlite3` + SQLite C extensions (notably `sqlite-vec`). -- **Supported platforms: Linux and macOS.** Windows is **unsupported — not tested, not - documented, not actively blocked.** A Windows user who installs their own C toolchain - (TDM-GCC or MinGW-w64) will probably get `go install` to succeed, but they're on their - own: no Windows CI, no Windows-specific bug fixes, no guarantee that future phases stay - Windows-buildable. Build-at-your-own-risk. -- Go ≥ 1.22 **plus a C toolchain** on every user's machine: - - Linux: `gcc` (pre-installed on most distros or via `build-essential`) - - macOS: Xcode Command Line Tools (`xcode-select --install`) - - Windows (unsupported): TDM-GCC or MinGW-w64 — user-installed, not on the happy path - -**Recommendation:** consolidate on Go. Keep `docsiq` as the base; port kgraph's feature -set (per-project identity, wikilink notes, frontmatter, hook installer, bearer auth, ZIP -export/import, FTS5 notes index, notes UI) into it. Retire the TypeScript codebase once -feature parity lands. Rationale: docsiq holds the harder-to-port pieces (PDF/DOCX -loaders, Louvain, LLM extraction, langchaingo); kgraph is 4.1k LoC of mostly small, -self-contained modules that map cleanly to Go. The end state is one binary, one language, -one build, installable anywhere Go runs. - ---- - -## 2. Project A — `docsiq` (Go) - -### 2.1 What it is -A self-contained GraphRAG pipeline: load → chunk → embed → extract entities/relationships/claims -→ detect Louvain communities → LLM-summarize communities → expose via MCP/REST/UI. Single static -binary, SQLite as the only datastore. - -### 2.2 Architecture - -``` -cmd/ Cobra CLI (root, index, serve, stats, version) -internal/ - loader/ PDF, DOCX, TXT, MD, web crawler - chunker/ Token-aware splitting w/ overlap - embedder/ Batched embeddings (Azure, Ollama) - extractor/ LLM JSON-mode entity/relationship/claim extraction - community/ Pure-Go Louvain + LLM report summarizer - search/ local (vec + graph walk) & global (community aggregation) - pipeline/ 5-phase orchestrator - store/ SQLite schema (documents, chunks, embeddings, entities, - relationships, claims, communities, community_reports) - llm/ Provider abstraction (Azure OpenAI, Ollama) - mcp/ 12 MCP tools over /mcp/sse - api/ REST handlers + router -ui/ React + Vite + vis-network, embedded via embed.go -``` - -### 2.3 Strengths -- Solid 5-phase GraphRAG with hierarchical Louvain — unusual to see in pure Go without CGO. -- 12 MCP tools covering both vector and graph queries, plus community-aggregated global search. -- Good separation of concerns; pipeline is replayable with `--finalize`. -- Single static binary; operationally trivial. -- Structured doc summaries (`get_document_structure`) + MCP console/UI for debugging tool calls. - -### 2.4 Gaps & weaknesses -| # | Gap | Impact | -|---|---|---| -| D1 | **No multi-project scoping** — one SQLite DB per install; all docs share a namespace | Cannot serve multiple repos/teams on one server; collides with kgraph's per-project model | -| D2 | **No auth on REST or MCP** | Cannot deploy beyond loopback without a reverse proxy | -| D3 | **No incremental / delta re-indexing** visible in CLI docs — only `--force` | Re-indexing a doc corpus is expensive; changed-file detection missing | -| D4 | **Providers limited to Azure + Ollama** (HuggingFace removed). No OpenAI direct, no Anthropic, no Bedrock, no Gemini | Users without Azure contracts must run Ollama locally | -| D5 | **No vector index** (HNSW/ivfflat). `embeddings.vector` is a BLOB — brute-force scan at query time | Query latency grows linearly with chunk count | -| D6 | **No reranker** after vector retrieval | Precision ceiling on local search | -| D7 | **Web crawler** exists (`internal/crawler/`) but no scheduling / refresh / sitemap handling documented | Web sources get stale | -| D8 | **No export / import** of the graph/data | Cannot migrate between machines or back up portably (cf. kgraph's ZIP export) | -| D9 | **UI has graph, search, stats, upload** but no edit/annotate/curate flow — read-only explorer | Cannot correct bad entity extractions | -| D10 | **Tests**: only `testdata/sample.md` visible; no `_test.go` files in the tree listing | Unknown test coverage; CI might be thin | -| D11 | **No hooks** — docsiq does not participate in AI session lifecycle | Doesn't feed context into coding agents; requires a separate mechanism | -| D12 | **No structured logging / metrics endpoint** (`/metrics`, tracing) | Hard to operate at scale | -| D13 | **CLAUDE.md** mentions completed integrations but not a user-facing changelog/roadmap | Unclear what's in flight | -| D14 | **Claims table** is populated but no MCP/REST surface to query claims directly | Dead data path | - ---- - -## 3. Project B — `kgraph` (TypeScript/Bun) - -### 3.1 What it is -A per-git-repo "memory" service. At session start, a hook resolves the current git remote to a -project and returns a message telling the agent to use MCP tools. Notes are plain markdown with -YAML frontmatter and `[[wikilinks]]`; SQLite is a derived FTS5 index over the notes folder. - -### 3.2 Architecture - -``` -src/ - core/ - db.ts registry (remotes→projects) + per-project FTS5 - project.ts project dir layout, open DB - notes.ts read/write/delete markdown + frontmatter - graph.ts build graph from wikilinks, related, tree - remote.ts normalize git remote URLs - watcher.ts (small, likely file-watch for notes→index sync) - api/ - routes.ts REST: projects, notes, graph, tree, search, export/import - hooks.ts POST /api/hook/SessionStart - mcp.ts MCP server: list_projects, list_notes, search_notes, - read_note, write_note, delete_note, get_graph, ... - server.ts Bun HTTP entrypoint (auth, routing, CORS) - index.ts CLI (init, serve, etc.) -hooks/ install.ts (426 LoC!), hook.mjs, hook.sh -ui/ React SPA: FolderTree, Graph, NoteEditor, NoteView, LinkPanel, TopBar -tests/ 10 files, ~1.6k LoC — meaningful coverage -deploy/ systemd setup.sh -``` - -### 3.3 Strengths -- Per-project isolation via git-remote identity — the *right* abstraction for agent workflows. -- Markdown-on-disk as source of truth, SQLite as derived index — portable, diff-friendly, - editable outside the app. -- Wikilinks as first-class graph edges — intuitive, user-authored graph structure. -- ZIP export/import, Docker image, systemd installer, Caddy guide — deployment story is richer - than docsiq. -- Bearer-token auth already implemented for both REST and MCP. -- Cross-client hook installer (Claude Code, Cursor, Copilot, Codex) — 426 LoC in `hooks/install.ts`. -- **Healthy test suite** (10 test files covering api, db, graph, wikilinks, project, remote, - frontmatter, hooks, notes, integration). -- Recent git history shows intentional minimalism ("lean session context 5 lines, 639 chars", - "move context injection from hook to MCP instructions"). - -### 3.4 Gaps & weaknesses -| # | Gap | Impact | -|---|---|---| -| K1 | **No vector search / embeddings** — only SQLite FTS5 | Semantic queries fail; "find notes about auth" matches literal tokens only | -| K2 | **No LLM integration whatsoever** — no extraction, no summarization, no auto-tagging | Notes are whatever the agent writes; no cross-note synthesis | -| K3 | **No community / cluster view** — graph is raw wikilink edges | Hard to navigate once >100 notes | -| K4 | **No PDF/DOCX/web ingestion** — notes only | Cannot absorb spec docs, tickets, design docs as first-class nodes | -| K5 | **Bun-only runtime** (Node support was dropped per git log) | Platform lock-in; some ops shops won't install Bun | -| K6 | **Graph-UI scales poorly** past a few hundred nodes (sim worker is naive, no LOD) | UX ceiling | -| K7 | **No query aggregation / "global" search** across projects | Cross-repo knowledge fragmented | -| K8 | **No metrics / observability** | Same ops concern as D12 | -| K9 | **Hooks focus on SessionStart only**; Stop hook was removed — notes only land via explicit MCP `write_note` | Agents that forget to call the tool lose context | -| K10 | **`hooks/install.ts` at 426 LoC** is the biggest file — likely over-engineered multi-client installer, fragile | Maintenance burden | -| K11 | **No versioning / history** of notes (git-in-git could help but isn't wired) | Decisions get overwritten silently | -| K12 | **Web UI uses custom theme + sim worker**; no shared design system with docsiq UI | Duplicated UI work if merged | - ---- - -## 4. Overlap & Complementarity Matrix - -| Concern | docsiq | kgraph | Overlap | Merge implication | -|---|---|---|---|---| -| Datastore | SQLite (1 DB, global) | SQLite (per project) + FS notes | Both SQLite | Adopt kgraph's per-project layout; nest docsiq tables inside each project DB | -| Project scoping | ❌ none | ✅ git-remote based | **kgraph wins** | Use kgraph identity model | -| Documents as input | ✅ PDF/DOCX/TXT/MD/web | ❌ | docsiq wins | Ingestion pipeline = docsiq | -| Notes as input | ❌ (could index MD) | ✅ first-class | kgraph wins | Note authoring = kgraph | -| Vector search | ✅ brute-force | ❌ | docsiq | Unify embeddings across both | -| Graph extraction | ✅ LLM-entity | ❌ (wikilinks only) | — | Keep both; merge at query time | -| Community detection | ✅ Louvain | ❌ | docsiq | Run over combined graph | -| MCP server | ✅ 12 tools, Go | ✅ 6 tools, TS | **both** | Single MCP server, merged toolset | -| REST API | ✅ | ✅ | **both** | Merge under /api/v1 | -| Web UI | React/Vite (ui/src) | React/Vite (ui/) | **both** | Single SPA, unified design tokens | -| Auth | ❌ | ✅ bearer | kgraph | Adopt kgraph scheme everywhere | -| Hooks | ❌ | ✅ multi-client installer | kgraph | Extend to inject doc-context too | -| Export/Import | ❌ | ✅ ZIP | kgraph | Extend ZIP to include docs + embeddings | -| Deploy docs | ❌ Makefile only | ✅ Docker+systemd+Caddy | kgraph | Absorb | -| Tests | thin | healthy | kgraph | Raise bar for Go side | - ---- - -## 5. Language Decision — **Go only** - -User constraint: one language. The two honest choices are "Go-only" or "TS-only"; the -effort and risk profiles are very different. - -### Port kgraph → Go (CHOSEN) -Rewrite kgraph's 4,134 LoC in Go, folding it into docsiq. kgraph is mostly thin modules -with clear contracts — most files are <350 LoC. - -| kgraph module | LoC | Go target | Notes | -|---|---|---|---| -| `src/core/db.ts` | 348 | `internal/store/notes.go` | SQLite FTS5 via **pure-Go** `modernc.org/sqlite` — already viable no-CGO | -| `src/core/graph.ts` | 126 | `internal/notes/graph.go` | Wikilink edge builder | -| `src/core/notes.ts` | 129 | `internal/notes/notes.go` | md read/write + frontmatter | -| `src/core/project.ts` | 100 | `internal/project/project.go` | Per-project dir layout | -| `src/core/remote.ts` | 28 | `internal/project/remote.go` | Git remote normalization | -| `src/core/watcher.ts` | 53 | `internal/notes/watcher.go` | `fsnotify` | -| `src/utils/*` | 50 | `internal/notes/{frontmatter,wikilinks}.go` | `yaml.v3` + regex | -| `src/api/routes.ts` | 316 | extend `internal/api/handlers.go` | Add notes/projects/export/import endpoints | -| `src/api/hooks.ts` | 38 | `internal/api/hooks.go` | SessionStart handler | -| `src/mcp.ts` | 221 | extend `internal/mcp/tools.go` | Add write_note/read_note/search_notes/list_projects | -| `src/server.ts` | 146 | fold into `cmd/serve.go` | Bearer auth middleware | -| `src/index.ts` | 339 | extend `cmd/*.go` | New cobra cmds: `init`, `projects`, `notes` | -| `hooks/install.ts` | **426** | `cmd/hooks.go` + `internal/hookinstaller/` | Rewrite per-client installer (Claude Code, Cursor, Copilot, Codex). Biggest single item. | -| `tests/*.ts` | ~1.6k | `*_test.go` | Rewrite (Go-idiomatic, table-driven) | -| `ui/` (React) | — | merge into `docsiq/ui/src/` | **No language change** — both are already React/Vite | - -**Effort estimate:** 3–5 weeks of focused work for core + API + MCP + installer; UI merge -in parallel; tests alongside each module. Total net new Go: ~3–4k LoC. - -**Pros** -- One static binary (no CGO — `modernc.org/sqlite` handles FTS5 without C). -- Cross-compiles to Linux/macOS/Windows from one toolchain. -- Docscontext's ops story (single binary) extends to the agent-memory features. -- Existing Go test idioms cover the new code. -- One `Makefile`, one CI, one release pipeline. - -**Cons** -- ~4 weeks of disciplined porting work before feature parity. -- Must rewrite the multi-client hook installer (the trickiest file). -- Temporary feature regression during the port — mitigated by phased migration (§9). - -### Port docsiq → TS (REJECTED) -- 6–8k LoC Go rewrite, including PDF/DOCX parsing, Louvain, embeddings math, langchaingo - Azure/Ollama wiring. -- PDF/DOCX TS libraries are noticeably flakier than Go equivalents (`ledongthuc/pdf`, - `unidoc`, `nguyenthenguyen/docx`). -- Loses the single-static-binary story; introduces Bun (or Node) runtime dependency. -- ~2.5–3 months of work vs. 3–5 weeks the other way. - -**Net verdict: Go.** - -### Shared UI note -Both projects already ship React + Vite frontends. Merging the SPAs is **not a language -change** — kgraph's `ui/components/{Graph, FolderTree, NoteEditor, NoteView, LinkPanel}` get -copied into `docsiq/ui/src/components/notes/` and wired through the existing -`App.tsx` + `TopNav`. Embedded via existing `ui/embed.go`. - ---- - -## 6. Recommended Unified Architecture (single Go binary) - -``` - ┌──────────────────────────────────────┐ - AI coding agent ────▶│ docsiq (Go binary) :8080 │ - (Claude/Cursor/etc.) │ │ - │ ─ cmd/ (cobra CLI) │ - │ init · serve · index · notes │ - │ projects · hooks · stats │ - │ │ - │ ─ MCP server (one unified toolset) │ - │ doc tools + note tools + graph │ - │ │ - │ ─ REST /api/v1 │ - │ ─ Embedded React SPA (ui/embed.go) │ - │ ─ Bearer auth middleware │ - │ ─ SessionStart hook endpoint │ - │ ─ Multi-client hook installer │ - │ │ - │ ─ Ingestion pipeline (5-phase) │ - │ ─ Notes (md on disk + wikilinks) │ - │ ─ Embeddings · Louvain · extraction │ - │ │ - │ ─ Per-project SQLite: │ - │ $DATA_DIR/projects// │ - │ index.db (docs+entities) │ - │ notes.db (FTS5 notes) │ - │ notes/*.md (source-of-truth)│ - └──────────────────────────────────────┘ -``` - -Optionally the two SQLite files can collapse into one once the schema is stable. - -**Unified primitives:** -- **Project identity** — normalized git remote (adopted from kgraph), persisted in a root - `$DATA_DIR/registry.db`. -- **Storage layout** — `$DATA_DIR/projects//` holds markdown notes on disk (kgraph - model, keeps them diff-able) plus the SQLite DB that now also hosts docsiq tables. -- **Embeddings** — the same embedder that vectorizes doc chunks also vectorizes notes, - so a single vector query can hit both. -- **Graph** — wikilink edges from notes + LLM-extracted entity edges from docs merge into - one graph table, with a `source` column (`note | doc-entity | doc-relationship`). -- **MCP toolset** — one server exposes both families: - - docs: `search_documents`, `local_search`, `global_search`, `query_entity`, - `find_relationships`, `get_graph_neighborhood`, `get_document_structure`, - `list_entities`, `list_documents`, `get_community_report`, `get_chunk`, `stats` - - notes: `list_projects`, `list_notes`, `search_notes`, `read_note`, `write_note`, - `delete_note`, `get_graph` (per project) -- **Auth** — single bearer key (`DOCSIQ_API_KEY`) in front of REST + MCP + hooks. -- **UI** — the docsiq React SPA gains a Notes module (folder tree, editor, wikilink - preview) imported from kgraph's UI components; one build, one bundle. - ---- - -## 7. Combined Feature Checklist (gaps to close on the way to "complete") - -Legend: ✅ present · 🟡 partial · ❌ missing - -### Ingestion & indexing -- ✅ PDF / DOCX / TXT / MD loaders (docsiq) -- ✅ Web crawler (docsiq) — 🟡 needs sitemap, scheduled refresh, robots.txt respect -- ❌ Incremental / changed-file re-index (mtime + content-hash) -- ❌ Repository-aware code indexer (treat `.py/.ts/.go` files, with symbol extraction) -- ❌ Issue tracker / Linear / GitHub ingestion -- ❌ Notes → embedded in the same vector space as docs (merge K1 + D1) - -### Retrieval -- ✅ Vector brute-force search (docsiq) -- ❌ **Vector index (HNSW via sqlite-vec or Chromem-go)** — address D5 -- ❌ **Hybrid search** (BM25/FTS5 + vector + reciprocal-rank-fusion) -- ❌ **Reranker** (cross-encoder or LLM) -- ✅ GraphRAG local / global search -- ❌ Cross-project search ("global" across all projects) -- ❌ Temporal filtering ("decisions from last quarter") - -### Knowledge structure -- ✅ Entities / relationships / claims (docsiq) — 🟡 claims unused at surface (D14) -- ✅ Louvain communities (docsiq) -- ✅ Wikilinks (kgraph) -- ❌ Unified node taxonomy across notes + entities -- ❌ Deduplication / entity resolution across docs + notes -- ❌ Note versioning / history (K11) - -### Agent integration -- ✅ MCP server (both — must merge) -- ✅ SessionStart hook (kgraph) -- ❌ Stop/PreCompact hook to auto-persist decisions (was removed; reconsider with guardrails) -- ❌ Per-tool-call attribution (which note/entity grounded each answer) -- ❌ Feedback loop (agent can flag wrong extractions; UI curation) - -### LLM providers -- ✅ Azure OpenAI (docsiq) -- ✅ Ollama (docsiq) -- ❌ OpenAI direct, Anthropic, Google Vertex, Bedrock, Groq -- ❌ Per-project provider override - -### Operations -- ✅ Docker (kgraph) — 🟡 docsiq has no Dockerfile visible -- ✅ systemd installer (kgraph) -- ❌ `/metrics` (Prometheus) or OTel tracing -- ❌ Structured JSON logs with request IDs -- ❌ Backup / snapshot command (extend kgraph ZIP export to include docsiq DB) -- ❌ Rate limiting on API / MCP -- 🟡 Bearer auth (kgraph only; missing in docsiq) -- ❌ Role-based scopes (read-only key vs. write key) -- ❌ Multi-user / multi-tenant - -### UX -- ✅ Graph explorer (both — must merge) -- ✅ Note editor (kgraph) -- ✅ Document upload (docsiq) -- ✅ Stats (docsiq) -- ❌ Unified search bar that hits both note & doc indexes -- ❌ Entity curation (merge, rename, reject) -- ❌ "Why this answer" citations in UI -- ❌ Mobile-responsive (kgraph has partial mobile; docsiq doesn't) - -### Quality -- ✅ TS tests (kgraph, 10 files) -- 🟡/❌ Go tests (docsiq — none visible) -- ❌ E2E ingestion fixture suite -- ❌ Eval harness for retrieval quality (recall@k on a labeled set) - ---- - -## 8. Per-Project Improvement Recommendations (applicable even if you don't merge) - -### docsiq -1. **Add per-project scoping** (`--project `, `$DATA_DIR/projects//`). This is a - precondition for merging and also useful standalone. -2. **Add bearer auth** mirroring kgraph (`DOCSIQ_API_KEY`). Minimal diff. -3. **Add vector index** via `sqlite-vec` or `chromem-go` to kill brute-force scan (D5). -4. **Add Go tests** for `internal/pipeline`, `internal/search`, `internal/store` — start with - table-driven cases on a small fixture corpus. -5. **Surface claims** as an MCP tool + REST endpoint (D14). -6. **Add Dockerfile + GH action** publishing `ghcr.io/.../docsiq`. -7. **Incremental re-index** keyed on content SHA + mtime. -8. **Export/import** (reuse kgraph's ZIP format shape). - -### kgraph -1. **Embeddings-backed search.** Either call out to docsiq (after merge) or embed - in-process via a small ONNX model. Addresses K1 and unblocks semantic search. -2. **Shrink `hooks/install.ts`** (426 LoC is a smell). Split per-client modules + a shared core. -3. **Restore Stop/PreCompact hook as opt-in,** with explicit agent-authored scratchpad only - (the reason it was removed was unbounded auto-write). -4. **Note history** via git-commit-on-write in `projects//notes/` — cheap durable - history, diff-able in UI. -5. **LOD/clustering** in the graph view for >300 nodes — swap sim worker for - d3-force-3d or pixi-based canvas. -6. **Re-add Node.js support** (behind a runtime shim) — Bun-only cuts adoption. -7. **Observability:** `/metrics`, request-id logs. - ---- - -## 9. Phased Merge Roadmap (Go-only) - -The kgraph repo stays read-only once Phase 1 starts — all new code lands in docsiq -(which is renamed to `docsiq` when Phase 4 ships). kgraph is archived after Phase 4. - -### Phase 0 — **Foundations** (week 1) — **COMPLETE (2026-04-17)** - -Delivered (unstaged in `docsiq/` repo, ready for review + commit): -- ✅ Env-var migration — `DOCSIQ_*` canonical, `DOCSIQ_*` deprecated aliases with - per-var + summary WARN logs. Environ-scan approach in `internal/config/config.go` - (+102 LoC). Config file search adds `~/.docsiq/` ahead of legacy `~/.docsiq/`. - Data dir default migrated to `~/.docsiq/data` with no-auto-move warn for legacy users. - `ServerConfig.APIKey` field added. 37 config subtests. -- ✅ Bearer auth middleware — `internal/api/auth.go` (~85 LoC). Policies: UI + `/health` - public, `/api/*` + `/mcp` gated, OPTIONS bypass, case-sensitive `"Bearer "` scheme, - `crypto/subtle` constant-time compare, JSON 401, `slog.Warn` on failure (never logs - token). 34-case adversarial test suite + 1 benchmark. -- ✅ `/health` endpoint — always-public `{"status":"ok"}`. -- ✅ `cmd/version.go` → `runtime/debug.ReadBuildInfo()` with `-ldflags` kept as override. - Preserves `make build` path; unlocks correct version strings for `go install`. 7 tests. -- ✅ Real `ui/dist/` built and committed (308 KB, Vite content-hash assets). -- ✅ `.github/workflows/ui-freshness.yml` — inlined workflow (NOT the external reusable - pipeline). Rebuilds `ui/dist/` on every PR and fails if the committed bundle drifts. -- ✅ Test baseline: **0 → 88 subtests** across `cmd`, `internal/api`, `internal/config`, - `internal/store`. `go build ./...`, `go vet ./...`, `go test ./...` all clean. -- ✅ `CONTRIBUTING.md` created explaining the `ui/dist/` commit requirement. - -**Deferred deliberately (audit-driven revisions to the original Phase 0 scope):** -- ⏸ Repo rename `docsiq/` → `docsiq/` — kept for a later phase once import-path - churn can ride alongside the kgraph port. -- ⏸ **SQLite driver swap to `mattn/go-sqlite3` + CGO** — moved to Phase 5 where - `sqlite-vec` actually lands. Phases 0–4 stay on `modernc.org/sqlite` (pure-Go, no - CGO). Strict improvement for early-release UX: Go-only install, no C toolchain. -- ⏸ CI CGO matrix (Linux + macOS) — unnecessary until the driver swap in Phase 5. - -**Findings surfacing follow-up work (tracked as separate tasks):** -- 🐛 `internal/store/store.go:21` DSN pragmas `?_journal_mode=WAL&_foreign_keys=on` - are `mattn/go-sqlite3` syntax and **silently ignored by `modernc.org/sqlite`**. - Runtime state observed: `journal_mode=delete`, `foreign_keys=0`. Despite schema - declaring `ON DELETE CASCADE`, FKs are not actually enforced today. Fix before - Phase 1 (per-project DBs where cascade semantics matter more). -- 🐛 `go test ./...` walks `ui/node_modules/flatted/golang/pkg/flatted` (a transitive - npm dep shipping a Go package). Harmless today but a deterministic-build hazard. -- ⚠ 3 npm audit findings (1 moderate, 2 high) — transitive dev deps. - -**Original Phase 0 items retained for reference (now carried into later phases):** -- `go install` distribution model: confirmed. Docs prereq is Go only while on modernc. - C-toolchain prereq added at Phase 5 alongside the driver swap. -- `ReadBuildInfo` for version: shipped ✅ - -### Phase 1 — **Per-project scope** (weeks 2–3) -- New root: `$DATA_DIR/registry.db` + `$DATA_DIR/projects//index.db`. -- Add `internal/project/` (project.go, remote.go) — git-remote normalization + slug. -- Every REST/MCP/CLI surface gains an optional `project` param (defaults to a `_default` - slug so existing users aren't broken). -- Migrate existing single-DB users via `docsiq migrate --into _default`. -- New cobra commands: `docsiq projects list|register|delete`, `docsiq init` (git-remote-aware). -- Go tests for project isolation (two projects, no cross-read). - -### Phase 2 — **Notes subsystem** (weeks 3–5) -Port kgraph's notes functionality. -- `internal/notes/`: `notes.go` (md read/write), `frontmatter.go` (yaml.v3), `wikilinks.go` - (regex + graph edges), `graph.go` (build graph from on-disk notes), `watcher.go` (fsnotify). -- `internal/store/notes.go`: SQLite FTS5 tables for notes, indexed from the md files. -- REST: `/api/projects/:p/notes/*key` (GET/PUT/DELETE), `/api/projects/:p/tree`, - `/api/projects/:p/search`, `/api/projects/:p/export`, `/api/projects/:p/import`. -- MCP: add `list_projects`, `list_notes`, `search_notes`, `read_note`, `write_note`, - `delete_note`, `get_graph` alongside the existing 12 doc tools. -- Notes-on-disk is source of truth; FTS5 is a rebuildable index (matches kgraph's model). -- Port kgraph's `tests/notes.test.ts`, `wikilinks.test.ts`, `frontmatter.test.ts` as Go - tests. - -### Phase 3 — **Hooks + cross-client installer** (weeks 5–7) -The highest-risk port. kgraph's `hooks/install.ts` is 426 LoC covering Claude Code, Cursor, -Copilot, Codex — each with its own config file format and MCP registration quirk. -- `cmd/hooks.go`: `docsiq hooks install [--client=claude|cursor|copilot|codex|all]`. -- `internal/hookinstaller/`: one file per client. Small shared helpers for JSON merging - and config-file location detection. -- `/api/hook/SessionStart` endpoint — returns `{additionalContext}` pointing agents at MCP - tools (same shape kgraph uses, so existing hook.sh/hook.mjs keep working). -- **Hook runtime script stays language-agnostic** and rides in the binary via `embed.FS`: - - `hook.sh` (bash + curl) — the only shipped hook script. - - `hook.mjs` is dropped (Node.js requirement eliminated on client machines). - - End-users of the AI clients need only a POSIX shell. - - Windows users (unsupported path) can run `hook.sh` under Git Bash / WSL, or BYO a - `hook.ps1` equivalent — we don't ship one. -- `docsiq hooks install` writes these scripts to `$DATA_DIR/hooks/` and registers them in - the selected AI client's config. -- Port kgraph's `tests/hooks.test.ts` + `project.test.ts` as Go tests. - -### Phase 4 — **UI merge + unified retrieval** (weeks 7–10) -- Copy kgraph's UI components (`FolderTree`, `Graph`, `NoteEditor`, `NoteView`, `LinkPanel`, - `simWorker`) into `ui/src/components/notes/`. Both sides already use React + Vite, so this - is file-level integration, not a rewrite. -- Add Notes tab to `TopNav`. Unified search bar hits notes FTS5 + doc hybrid retrieval; - results labeled `[note]/[doc]/[entity]`. -- Embed notes in the same vector space as doc chunks — one embedder, one search path. -- Merge wikilink edges and LLM-extracted entity edges into a single graph rendering; legend - distinguishes sources. -- Rebrand: binary name becomes `docsiq`, `docsiq` kept as a symlink for one release. -- Archive the kgraph repo (README pointing to docsiq). - -### Phase 5 — **Ops + retrieval quality** (weeks 10–12) -Now that everything is in one codebase, knock down the gaps from §7: -- **Vector index — `sqlite-vec`** loaded via `db.Exec("SELECT load_extension('vec0')")`. - CGO lets us use the mature C extension directly; ANN queries are milliseconds at 1M+ - vectors. Ship `vec0.so` (Linux) and `vec0.dylib` (macOS) embedded via `embed.FS`, - extracted to `$DATA_DIR/ext/` on first run. -- Hybrid search (FTS5 + vector + RRF) and optional LLM reranker. -- Incremental re-index (content-hash + mtime). -- Surface `claims` via MCP/REST (D14). -- `/metrics` (Prometheus) + structured JSON logs with request IDs. -- Eval harness (recall@k) with a labeled fixture set. -- Per-project LLM provider override in config. - -### Phase 6 — **Optional: stretch** (beyond week 12) -- Additional providers (OpenAI direct, Anthropic, Vertex, Bedrock, Groq) — pluggable via - the existing `internal/llm/provider.go` interface. -- Note history via auto-commit-on-write into a hidden `projects//notes/.git`. -- Entity resolution / deduplication (note "JWT" ↔ doc entity "JSON Web Token"). -- Multi-user auth (RBAC, per-project scopes) — only if deployment model demands it. - ---- - -## 10. Risks, Open Questions, Decisions to Make - -### Risks -- **Multi-client hook installer port** is the single riskiest item — 426 LoC covering four - AI clients each with quirks. Mitigation: keep the hook runtime as language-agnostic glue - (`hook.sh` + new `hook.ps1`) embedded via `embed.FS`; only port the *installer* logic - (file discovery + JSON merging), not the hook runtime itself. -- **Stale `ui/dist/` in git** — `go install` has no way to rebuild the UI, so a committed - `ui/dist/` that drifts from `ui/src/` will ship broken UIs to users. Mitigation: CI job - rebuilds `ui/dist/` on every PR and fails if the committed bundle doesn't match; optional - pre-commit hook locally. -- **`go install` first-run cost** — each user pays a 10–30s Go+C compile the first time - (and every version bump). CGO roughly doubles this vs. pure Go. Mitigation: accept it; - document it; target audience already runs `go install` for other tools. -- **CGO breaks `go install` on machines with no C compiler at all** (bare minimal Docker - images, some CI environments). Mitigation: documented Linux/macOS prereqs. -- **`sqlite-vec` extension file distribution** — `.so` and `.dylib` must be shipped with - the binary. Embedding via `embed.FS` and extracting to `$DATA_DIR/ext/` on first run - works, but adds ~2 MB to the binary (one per supported OS). Mitigation: accept — it's - the price of ANN. (Windows users on the unsupported path get a build-time error telling - them to drop a `vec0.dll` into `$DATA_DIR/ext/` themselves.) -- **Feature regression during port** — current kgraph users lose functionality until Phase 2 - ships. Mitigation: don't archive kgraph repo until Phase 4 is green; users stay on kgraph - until docsiq reaches parity. -- **SQLite driver swap** (`mattn/go-sqlite3` → `modernc.org/sqlite`) is a behavior change - even though SQL is identical — modernc has no CGO but different error messages and - marginally different performance. Mitigation: run the existing test corpus against both - drivers once before committing to the swap. -- **LLM cost inflation** once notes are auto-embedded. Mitigation: embed on demand, dedupe - by content hash. -- **Entity dedup / resolution** at graph merge time ("JWT" note ↔ "JSON Web Token" entity). - Mitigation: start with vector similarity threshold; defer true ER to Phase 6. - -### Open questions (for the human to decide) -1. **Target deployment model** — single laptop / team VM / hosted SaaS? Drives auth and - multi-tenancy priorities. -2. **Provider preference** — is Azure + Ollama enough, or must Phase 5/6 ship - OpenAI/Anthropic/Bedrock? -3. **Naming** — keep the umbrella as `docsiq`, rename the Go binary to `docsiq`, retire both - `docsiq` and `kgraph` names? Or keep `docsiq` as the binary and "docsiq" only as - the project label? -4. **Migration path** — existing kgraph users on `~/.kgraph/` — do we ship a one-shot - `docsiq migrate --from-kgraph ~/.kgraph` importer? -5. **Licensing** — docsiq has a LICENSE; kgraph's license status should match. -6. **Who uses this** — solo devs, teams, enterprises? Drives RBAC vs. single-key auth. -7. **Notes versioning now or later** — is `.git`-on-notes-dir worth wiring in Phase 2, or - defer to Phase 6? - ---- - -## 11. Quick-Win Punch List (can start Monday, no merge needed) - -| # | Task | Phase | Effort | Value | -|---|---|---|---|---| -| 1 | Add bearer auth middleware to REST+MCP | 0 | S | Unblocks remote deploy, parity with kgraph | -| 2 | Settle on `mattn/go-sqlite3` + `sqlite_fts5` build tag (CGO on) | 0 | S | Mature SQLite + enables `sqlite-vec` later | -| 3 | CI matrix: Linux/macOS with `CGO_ENABLED=1` | 0 | S | Catches toolchain regressions pre-release | -| 4 | Commit pre-built `ui/dist/` + CI freshness check | 0 | S | Mandatory for `go install` UI shipping | -| 5 | Switch version string to `runtime/debug.ReadBuildInfo()` | 0 | S | Version info survives `go install` | -| 6 | Add `project` scope everywhere | 1 | M | Precondition for notes merge | -| 7 | Port kgraph notes core (notes.go, frontmatter, wikilinks) | 2 | M | First visible "merged" feature | -| 8 | Port kgraph's test fixtures to `_test.go` | 2 | M | Baseline coverage | -| 9 | Port multi-client hook installer; keep only `hook.sh` (drop Node/PowerShell) | 3 | L | Agent integration, zero client-side runtime deps | -| 10 | Merge UI (copy kgraph React components into `ui/src/`) | 4 | M | One SPA | -| 11 | Surface `claims` via MCP/REST | 5 | S | Uses existing data | -| 12 | Wire `sqlite-vec` (embed extension + loadExtension on boot) | 5 | M | Proper ANN index, scales past 1M vectors | - ---- - -## 12. Appendix — File & LoC Snapshot - -### docsiq (key files) -- `cmd/`: root, serve, index, stats, version -- `internal/store/store.go` (schema: documents, chunks, embeddings, entities, relationships, - claims, communities, community_reports) -- `internal/pipeline/pipeline.go` (5-phase orchestration) -- `internal/mcp/{server,tools}.go` (12 tools) -- `internal/api/{router,handlers}.go` -- `internal/search/{local,global}.go` -- `internal/community/{louvain,summarizer}.go` -- `internal/extractor/{entities,claims}.go` -- `ui/src/components/docs/*` + `ui/src/components/mcp/*` React SPA - -### kgraph (total 4,134 LoC) -- `src/core/db.ts` (348) — registry + FTS5 -- `src/index.ts` (339) — CLI -- `src/api/routes.ts` (316) — REST surface -- `src/mcp.ts` (221) — MCP tools -- `src/server.ts` (146) — HTTP entrypoint -- `src/core/{graph,notes,project,remote,watcher}.ts` -- `src/utils/{frontmatter,wikilinks,runtime}.ts` -- `hooks/install.ts` (426) — cross-client installer (**largest file**) -- `tests/*.ts` (10 files, ~1.6k LoC) -- `ui/components/{TopBar,Graph,FolderTree,NoteView,NoteEditor,LinkPanel,simWorker}.tsx` - -### Git state -- docsiq: clean, on `main`, up-to-date with origin. -- kgraph: clean, on `main`; recent history shows deliberate pruning (MCP-over-hook migration, - Bun-only pivot). - ---- - -**End of plan. No source files modified.** diff --git a/docs/superpowers/plans/2026-04-17-quality-sweep.md b/docs/superpowers/plans/2026-04-17-quality-sweep.md deleted file mode 100644 index 8dbb56d..0000000 --- a/docs/superpowers/plans/2026-04-17-quality-sweep.md +++ /dev/null @@ -1,1662 +0,0 @@ -# docsiq Quality Sweep — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close all correctness, test-coverage, and documentation gaps from the kgraph→docsiq port without expanding feature scope. Result: a product that is correct and defensible, not larger. - -**Architecture:** Six-wave execution — four parallel (review, frontend tests, integration tests, hooks+MCP+docs) followed by two serial (review-remediation, lint sweep) and final verification. File-isolation contracts prevent merge conflicts; severity rubric (P0/P1/P2) bounds remediation scope. - -**Tech Stack:** Go 1.22 (CGO + `sqlite_fts5`), `mattn/go-sqlite3`, `modernc`-free; React 19 + Vite 7 + Vitest + @testing-library/react; `httptest` + `go.uber.org/goleak` for Go integration. - -**Spec:** `docs/superpowers/specs/2026-04-17-quality-sweep-design.md` - ---- - -## File Structure - -**Created:** -- `REVIEW.md` (root) — Wave A output, consumed by Wave E -- `ui/vitest.config.ts`, `ui/src/setupTests.ts` — Wave B -- `ui/src/**/__tests__/*.test.tsx` — Wave B (~12 files) -- `internal/api/itest/harness.go`, `internal/api/itest/doubles.go` — Wave C -- `internal/**/*_integration_test.go` — Wave C (10 files) -- `internal/hookinstaller/fixtures/**/{before,after}.json` — Wave D1 -- `docs/README.md`, `docs/getting-started.md`, `docs/cli-reference.md`, `docs/mcp-tools.md`, `docs/rest-api.md`, `docs/config.md`, `docs/hooks.md`, `docs/architecture.md` — Wave D4 -- `/home/dev/projects/docsiq/kgraph/ARCHIVED.md` — Wave D3 (sibling repo) - -**Modified:** -- `ui/package.json` — Wave B (add vitest + RTL devDeps, test scripts) -- `Makefile` — Wave C (add `test-integration` target), Wave B (add `test-ui` target) -- `.github/workflows/ci.yml` — Wave B (add vitest job), Wave C (add integration-test job) -- `internal/hookinstaller/{claude,cursor,copilot,codex}.go` — Wave D1 -- `internal/mcp/tools.go` — Wave D2 (global_search per-project LLM) -- `CLAUDE.md` — Wave D3 (rebrand) -- `/home/dev/projects/docsiq/UNIFICATION-PLAN.md` — Wave D3 (rebrand + status banner) -- `README.md` — Wave D1 (add hook support matrix) - ---- - -## Wave A — Code Review (single task) - -### Task A1: Dispatch code reviewer - -**Files:** -- Create: `/home/dev/projects/docsiq/docscontext/REVIEW.md` - -- [ ] **Step 1: Dispatch `feature-dev:code-reviewer` agent** - -Use this exact prompt: - -``` -You are doing a comprehensive code review of the docsiq repo at -/home/dev/projects/docsiq/docscontext/ — 7 feature commits since -the port of kgraph into Go (commits 3d2d2ce..790810f on main, -~12,000 LoC). - -Do NOT fix anything. Write findings to REVIEW.md at repo root. - -Review axes (mandatory checklist): -1. Correctness — races (projectStores cache, VectorIndexes map, - note auto-commit mutexes), silent error-drop, FTS5 snippet/rank - off-by-one, goroutine lifecycle on shutdown -2. Security — auth bypass, path traversal (note keys, tar import, - hook installer paths), token logging, CSRF on REST -3. Concurrency — cache races, concurrent note writers, shutdown - ordering -4. Resource leaks — unclosed sql.DB/sql.Rows/readers, tmpdir - cleanup, goroutine leaks in upload-progress -5. API contract — HTTP status codes, JSON shapes, MCP tool arg - validation -6. Test coverage gaps — paths not exercised by 424 existing subtests -7. Consistency — style drift, duplicated logic, naming - -Severity rubric: -- P0 — bugs, data-loss, auth bypass, races → must fix -- P1 — correctness gaps, wrong error handling, misleading docs -- P2 — style, refactor opportunities, defer-OK - -Output REVIEW.md with this structure: - - # Code Review — docsiq quality sweep - ## Summary - - N findings total: X P0 / Y P1 / Z P2 - - Packages audited: [list] - - Lines audited: ~12k - ## P0 — must fix - ### [P0-1] — <file:line> - **What:** one-sentence description - **Impact:** concrete consequence - **Evidence:** code excerpt or test scenario - **Recommended fix:** approach (not code) - ## P1 — should fix ... same shape ... - ## P2 — nice to have / defer ... same shape ... - ## What looks good - <intentional non-findings to confirm reviewer saw something and - decided it was OK> - -Key files to audit in depth: -- internal/api/stores.go (per-project store cache — race hotspot) -- internal/api/vector_indexes.go (per-project HNSW cache) -- internal/notes/notes.go + history.go (per-project mutex + git exec) -- internal/notes/graph.go (walks disk every call) -- internal/hookinstaller/*.go (cross-client JSON merge atomicity) -- internal/mcp/tools.go + notes_tools.go (arg validation) -- internal/api/auth.go (scheme parsing, constant-time compare) -- internal/api/router.go (middleware ordering) -- cmd/serve.go (shutdown ordering, registry+stores lifecycle) -- internal/store/store.go (DSN pragmas, schema migrations) -- internal/vectorindex/hnsw.go (concurrent Add/Search safety) -- internal/sqlitevec/load.go (extension loading error handling) - -Do NOT modify any *.go files. Only write REVIEW.md. -``` - -- [ ] **Step 2: Verify REVIEW.md exists and has findings** - -Run: `ls -la REVIEW.md && head -20 REVIEW.md` -Expected: file exists, "# Code Review — docsiq quality sweep" header present, Summary section populated. - -- [ ] **Step 3: Commit** - -```bash -git add REVIEW.md -git commit -m "docs: add Wave A code review findings" -``` - ---- - -## Wave B — Frontend Tests - -### Task B1: Install vitest + testing-library stack - -**Files:** -- Modify: `ui/package.json` (devDependencies + scripts) -- Create: `ui/vitest.config.ts` -- Create: `ui/src/setupTests.ts` - -- [ ] **Step 1: Add devDeps** - -```bash -cd ui && npm install --save-dev \ - vitest@^2.1 \ - @vitest/coverage-v8@^2.1 \ - @testing-library/react@^16 \ - @testing-library/user-event@^14 \ - @testing-library/jest-dom@^6 \ - jsdom@^25 -``` - -- [ ] **Step 2: Create `ui/vitest.config.ts`** - -```ts -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./src/setupTests.ts"], - coverage: { - reporter: ["text", "html"], - include: [ - "src/components/notes/**", - "src/components/nav/**", - "src/components/shared/**", - "src/hooks/**", - ], - thresholds: { - statements: 70, - branches: 60, - }, - }, - }, -}); -``` - -- [ ] **Step 3: Create `ui/src/setupTests.ts`** - -```ts -import "@testing-library/jest-dom"; -``` - -- [ ] **Step 4: Add scripts to `ui/package.json`** - -Add to the "scripts" object: -```json -"test": "vitest run", -"test:watch": "vitest", -"test:coverage": "vitest run --coverage" -``` - -- [ ] **Step 5: Smoke-test the pipeline** - -```bash -cd ui && npm test -- --run src/setupTests.ts 2>&1 | head -10 -``` -Expected: "no test files" is fine; what matters is `vitest` launches without config errors. If error, fix before proceeding. - -- [ ] **Step 6: Commit** - -```bash -git add ui/package.json ui/package-lock.json ui/vitest.config.ts ui/src/setupTests.ts -git commit -m "test: add vitest + testing-library stack for ui" -``` - ---- - -### Task B2: FolderTree component tests - -**Files:** -- Create: `ui/src/components/notes/__tests__/FolderTree.test.tsx` - -- [ ] **Step 1: Write the failing test file** - -```tsx -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { FolderTree } from "../FolderTree"; - -const sampleTree = { - name: "", - children: [ - { name: "architecture", children: [{ name: "auth.md", key: "architecture/auth" }] }, - { name: "intro.md", key: "intro" }, - ], -}; - -describe("FolderTree", () => { - it("renders folder and file nodes", () => { - render(<FolderTree tree={sampleTree} onSelect={() => {}} onCreate={() => {}} />); - expect(screen.getByText("architecture")).toBeInTheDocument(); - expect(screen.getByText("intro.md")).toBeInTheDocument(); - }); - - it("click on a file calls onSelect with key", async () => { - const user = userEvent.setup(); - const onSelect = vi.fn(); - render(<FolderTree tree={sampleTree} onSelect={onSelect} onCreate={() => {}} />); - await user.click(screen.getByText("intro.md")); - expect(onSelect).toHaveBeenCalledWith("intro"); - }); - - it("+ button opens the create-note modal", async () => { - const user = userEvent.setup(); - render(<FolderTree tree={sampleTree} onSelect={() => {}} onCreate={() => {}} />); - await user.click(screen.getByRole("button", { name: /new note/i })); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("rejects invalid keys inline", async () => { - const user = userEvent.setup(); - const onCreate = vi.fn(); - render(<FolderTree tree={sampleTree} onSelect={() => {}} onCreate={onCreate} />); - await user.click(screen.getByRole("button", { name: /new note/i })); - await user.type(screen.getByLabelText(/key/i), "../escape"); - await user.click(screen.getByRole("button", { name: /create/i })); - expect(screen.getByText(/invalid/i)).toBeInTheDocument(); - expect(onCreate).not.toHaveBeenCalled(); - }); - - it("Escape closes the modal without creating", async () => { - const user = userEvent.setup(); - const onCreate = vi.fn(); - render(<FolderTree tree={sampleTree} onSelect={() => {}} onCreate={onCreate} />); - await user.click(screen.getByRole("button", { name: /new note/i })); - await user.keyboard("{Escape}"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(onCreate).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run tests; adapt to actual component props if they differ** - -```bash -cd ui && npm test -- --run src/components/notes/__tests__/FolderTree.test.tsx -``` -Expected: 5 tests run. If any fails because the component's prop names / ARIA labels differ from the test's assumptions, inspect `src/components/notes/FolderTree.tsx` and adjust the test queries. Do NOT change component source in this task. - -- [ ] **Step 3: Commit** - -```bash -git add ui/src/components/notes/__tests__/FolderTree.test.tsx -git commit -m "test(ui): FolderTree component tests" -``` - ---- - -### Task B3: NoteView markdown renderer tests - -**Files:** -- Create: `ui/src/components/notes/__tests__/NoteView.test.tsx` - -- [ ] **Step 1: Write the test file** - -```tsx -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { NoteView } from "../NoteView"; - -describe("NoteView markdown", () => { - it("renders headings", () => { - render(<NoteView note={{ key: "k", content: "# Hello\n\n## World" }} onNavigate={() => {}} />); - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello"); - expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("World"); - }); - - it("renders bold, italic, code", () => { - render(<NoteView note={{ key: "k", content: "**bold** *italic* `code`" }} onNavigate={() => {}} />); - expect(screen.getByText("bold").tagName).toBe("STRONG"); - expect(screen.getByText("italic").tagName).toBe("EM"); - expect(screen.getByText("code").tagName).toBe("CODE"); - }); - - it("renders wikilinks clickable", async () => { - const user = userEvent.setup(); - const onNavigate = vi.fn(); - render(<NoteView note={{ key: "k", content: "See [[target]]." }} onNavigate={onNavigate} />); - const link = screen.getByRole("link", { name: "target" }); - await user.click(link); - expect(onNavigate).toHaveBeenCalledWith("target"); - }); - - it("renders external markdown links with noopener", () => { - render(<NoteView note={{ key: "k", content: "[docsiq](https://example.com)" }} onNavigate={() => {}} />); - const link = screen.getByRole("link", { name: "docsiq" }) as HTMLAnchorElement; - expect(link.href).toBe("https://example.com/"); - expect(link.target).toBe("_blank"); - expect(link.rel).toMatch(/noopener/); - }); - - it("renders images with lazy loading", () => { - const { container } = render(<NoteView note={{ key: "k", content: "![alt](/pic.png)" }} onNavigate={() => {}} />); - const img = container.querySelector("img"); - expect(img?.loading).toBe("lazy"); - expect(img?.alt).toBe("alt"); - }); - - it("renders blockquotes", () => { - const { container } = render(<NoteView note={{ key: "k", content: "> quoted line\n> more" }} onNavigate={() => {}} />); - expect(container.querySelector("blockquote")).toBeInTheDocument(); - }); - - it("renders GitHub tables", () => { - const md = "| a | b |\n|---|---|\n| 1 | 2 |"; - const { container } = render(<NoteView note={{ key: "k", content: md }} onNavigate={() => {}} />); - expect(container.querySelector("table")).toBeInTheDocument(); - expect(container.querySelectorAll("td")).toHaveLength(2); - }); - - it("renders horizontal rule", () => { - const { container } = render(<NoteView note={{ key: "k", content: "a\n\n---\n\nb" }} onNavigate={() => {}} />); - expect(container.querySelector("hr")).toBeInTheDocument(); - }); - - it("strips frontmatter from display", () => { - const content = "---\ntitle: Hidden\n---\n\nVisible body"; - render(<NoteView note={{ key: "k", content }} onNavigate={() => {}} />); - expect(screen.queryByText("title: Hidden")).not.toBeInTheDocument(); - expect(screen.getByText("Visible body")).toBeInTheDocument(); - }); - - it("handles empty body", () => { - const { container } = render(<NoteView note={{ key: "k", content: "" }} onNavigate={() => {}} />); - expect(container.firstChild).toBeTruthy(); - }); -}); -``` - -- [ ] **Step 2: Run tests** - -```bash -cd ui && npm test -- --run src/components/notes/__tests__/NoteView.test.tsx -``` -Expected: 10 tests. Any unexpected failures → inspect NoteView source, adjust test queries to match actual DOM. Record unexpected renderer behaviour as a potential P1 finding (note in commit message if so). - -- [ ] **Step 3: Commit** - -```bash -git add ui/src/components/notes/__tests__/NoteView.test.tsx -git commit -m "test(ui): NoteView markdown rendering" -``` - ---- - -### Task B4: NoteEditor, LinkPanel, NotesGraphView, NotesSearchPanel, UnifiedSearchPanel, TopNav, App tests - -Each gets its own `__tests__/*.test.tsx` file with the test cases from spec §4 table. For each component: - -**Files per component:** -- Create: `ui/src/components/<path>/__tests__/<Component>.test.tsx` - -Pattern (apply per component): - -- [ ] **Step 1: Read the component source** - ```bash - cat ui/src/components/<path>/<Component>.tsx - ``` - Note: actual prop names and ARIA labels to query by. - -- [ ] **Step 2: Write the test file** covering every bullet in the spec §4 row for this component. Include at minimum: basic render, interaction that calls a callback, empty / loading / error states where applicable. - -- [ ] **Step 3: Run tests** - ```bash - cd ui && npm test -- --run src/components/<path>/__tests__/<Component>.test.tsx - ``` - -- [ ] **Step 4: Commit** - ```bash - git add ui/src/components/<path>/__tests__/<Component>.test.tsx - git commit -m "test(ui): <Component> component tests" - ``` - -Components to cover in this task (one commit per component): -- [ ] `NoteEditor` — body update, tag input parse, save → writeNote, dirty-flag warn -- [ ] `LinkPanel` — inbound/outbound lists, empty state, click navigates -- [ ] `NotesGraphView` — N nodes render, empty state, note-accent class applied -- [ ] `NotesSearchPanel` — input debounce, results render, snippet highlight, click navigates, count+ms shown -- [ ] `shared/UnifiedSearchPanel` — fires both endpoints in parallel, merges labels, empty results state -- [ ] `nav/TopNav` — tabs + project selector, URL sync on change -- [ ] `App` (top-level) — initial tab from URL, tab switch updates URL, project switch reloads hooks - ---- - -### Task B5: Hook tests (useNotes, useProjects, useNotesSearch, useNotesGraph, useNotesTree) - -**Files:** -- Create: `ui/src/hooks/__tests__/useNotes.test.tsx` and siblings - -- [ ] **Step 1: Set up fetch mocking** - -Add to top of each hook test file: -```tsx -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; - -beforeEach(() => { - global.fetch = vi.fn(); -}); -afterEach(() => { - vi.restoreAllMocks(); -}); - -function mockFetch(status: number, body: unknown) { - (global.fetch as any).mockResolvedValueOnce({ - ok: status >= 200 && status < 300, - status, - json: async () => body, - text: async () => JSON.stringify(body), - }); -} -``` - -- [ ] **Step 2: `useNotes.test.tsx`** - -```tsx -import { useNotes, writeNote, deleteNote } from "../useNotes"; - -describe("useNotes", () => { - it("calls /api/projects/:p/notes and returns list", async () => { - mockFetch(200, [{ key: "a" }, { key: "b" }]); - const { result } = renderHook(() => useNotes("_default")); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/projects/_default/notes"), - expect.any(Object), - ); - expect(result.current.notes).toHaveLength(2); - }); - - it("exposes error on 500", async () => { - mockFetch(500, { error: "boom" }); - const { result } = renderHook(() => useNotes("_default")); - await waitFor(() => expect(result.current.error).toBeTruthy()); - }); -}); - -describe("writeNote", () => { - it("PUTs content to /api/projects/:p/notes/:key", async () => { - mockFetch(200, { key: "x" }); - await writeNote("_default", "x", "body", "me", ["t"]); - const call = (global.fetch as any).mock.calls[0]; - expect(call[0]).toMatch(/\/api\/projects\/_default\/notes\/x/); - expect(call[1].method).toBe("PUT"); - expect(JSON.parse(call[1].body)).toMatchObject({ - content: "body", author: "me", tags: ["t"], - }); - }); - - it("rejects empty content locally (no PUT)", async () => { - await expect(writeNote("_default", "x", "", undefined, [])).rejects.toThrow(); - expect(global.fetch).not.toHaveBeenCalled(); - }); -}); - -describe("deleteNote", () => { - it("DELETEs the note", async () => { - mockFetch(204, null); - await deleteNote("_default", "x"); - expect((global.fetch as any).mock.calls[0][1].method).toBe("DELETE"); - }); -}); -``` - -- [ ] **Step 3: Run** — `cd ui && npm test -- --run src/hooks/__tests__/useNotes.test.tsx`. All pass. - -- [ ] **Step 4: Commit.** - -- [ ] Repeat Steps 2-4 for each hook: `useProjects`, `useNotesSearch`, `useNotesGraph`, `useNotesTree`. - ---- - -### Task B6: Verify coverage floor - -- [ ] **Step 1: Run coverage** - -```bash -cd ui && npm run test:coverage -``` -Expected: statements ≥ 70% on `components/{notes,nav,shared}/**` and `hooks/**`; branches ≥ 60%. If below threshold, identify uncovered files, add targeted tests. - -- [ ] **Step 2: Wire into Makefile** - -Edit `Makefile`, add target: -``` -test-ui: - cd ui && npm test -- --run - -test-ui-coverage: - cd ui && npm run test:coverage -``` -Add `test-ui` to the `check` target's prerequisites. - -- [ ] **Step 3: Wire into ci.yml** - -Edit `.github/workflows/ci.yml`, add inside the `ui-freshness` job (after the `npm run build` step): -```yaml - - name: vitest - run: npm --prefix ui test -- --run --coverage -``` - -- [ ] **Step 4: Commit** - -```bash -git add Makefile .github/workflows/ci.yml -git commit -m "ci: wire vitest into Makefile + CI workflow" -``` - ---- - -## Wave C — Integration Tests - -### Task C1: Integration harness - -**Files:** -- Create: `internal/api/itest/harness.go` -- Create: `internal/api/itest/doubles.go` - -- [ ] **Step 1: Add `go.uber.org/goleak`** - -```bash -CGO_ENABLED=1 go get go.uber.org/goleak@latest -CGO_ENABLED=1 go mod tidy -``` - -- [ ] **Step 2: Write `internal/api/itest/harness.go`** - -```go -//go:build integration - -package itest - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/RandomCodeSpace/docsiq/internal/api" - "github.com/RandomCodeSpace/docsiq/internal/config" - "github.com/RandomCodeSpace/docsiq/internal/embedder" - "github.com/RandomCodeSpace/docsiq/internal/llm" - "github.com/RandomCodeSpace/docsiq/internal/project" -) - -type Env struct { - Server *httptest.Server - DataDir string - Registry *project.Registry - Stores *api.ProjectStores - APIKey string - Client *http.Client -} - -func New(t *testing.T) *Env { - t.Helper() - dir := t.TempDir() - reg, err := project.OpenRegistry(dir) - if err != nil { - t.Fatalf("OpenRegistry: %v", err) - } - t.Cleanup(func() { _ = reg.Close() }) - - stores := api.NewProjectStores(dir) - t.Cleanup(func() { stores.CloseAll() }) - - keyBytes := make([]byte, 16) - _, _ = rand.Read(keyBytes) - apiKey := hex.EncodeToString(keyBytes) - - cfg := &config.Config{DataDir: dir, DefaultProject: "_default"} - cfg.Server.APIKey = apiKey - - prov := &FakeProvider{} - emb := embedder.New(prov, 4) - - handler := api.NewRouter(prov, emb, cfg, reg, - api.WithProjectStores(stores), - ) - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - - return &Env{ - Server: srv, - DataDir: dir, - Registry: reg, - Stores: stores, - APIKey: apiKey, - Client: srv.Client(), - } -} - -func (e *Env) authReq(method, path string, body []byte) *http.Request { - req, _ := http.NewRequest(method, e.Server.URL+path, bytes.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+e.APIKey) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - return req -} - -func (e *Env) PUTNote(t *testing.T, project, key, content string, tags []string) *http.Response { - t.Helper() - body, _ := json.Marshal(map[string]any{ - "content": content, "author": "tester", "tags": tags, - }) - req := e.authReq("PUT", "/api/projects/"+project+"/notes/"+key+"?project="+project, body) - resp, err := e.Client.Do(req) - if err != nil { - t.Fatalf("PUTNote: %v", err) - } - return resp -} - -func (e *Env) GET(t *testing.T, path string) (*http.Response, []byte) { - t.Helper() - req := e.authReq("GET", path, nil) - resp, err := e.Client.Do(req) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return resp, b -} - -func (e *Env) DB() string { - return filepath.Join(e.DataDir, "projects", "_default", "docsiq.db") -} -``` - -- [ ] **Step 3: Write `internal/api/itest/doubles.go`** - -```go -//go:build integration - -package itest - -import ( - "context" - "crypto/sha256" - "encoding/binary" - - "github.com/RandomCodeSpace/docsiq/internal/llm" -) - -// FakeProvider is a deterministic test double. -type FakeProvider struct{ CallCount int } - -func (p *FakeProvider) Name() string { return "fake" } -func (p *FakeProvider) ModelID() string { return "fake-model-v1" } - -func (p *FakeProvider) Chat(ctx context.Context, prompt string, opts ...llm.ChatOption) (string, error) { - p.CallCount++ - return "fake-chat-response", nil -} - -func (p *FakeProvider) Embed(ctx context.Context, texts []string) ([][]float32, error) { - p.CallCount++ - out := make([][]float32, len(texts)) - for i, t := range texts { - out[i] = deterministic(t, 384) - } - return out, nil -} - -func deterministic(text string, dim int) []float32 { - h := sha256.Sum256([]byte(text)) - vec := make([]float32, dim) - for i := 0; i < dim; i++ { - off := (i * 4) % len(h) - bits := binary.LittleEndian.Uint32(h[off : off+4 : off+4]) - vec[i] = float32(bits) / float32(1<<32) - } - return vec -} -``` - -- [ ] **Step 4: Add `test-integration` to Makefile** - -``` -test-integration: - CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -timeout 600s $(GO_PKGS) -``` - -- [ ] **Step 5: Smoke-test the harness builds (no tests yet)** - -```bash -CGO_ENABLED=1 go build -tags "sqlite_fts5 integration" ./internal/api/itest/... -``` -Expected: clean build. If signature mismatches on api.NewRouter or similar, inspect current signatures and adapt. If FakeProvider doesn't satisfy llm.Provider, read `internal/llm/provider.go` and add the missing methods. - -- [ ] **Step 6: Commit** - -```bash -git add go.mod go.sum internal/api/itest/*.go Makefile -git commit -m "test: integration harness + FakeProvider" -``` - ---- - -### Task C2: auth_integration_test.go - -**Files:** -- Create: `internal/api/auth_integration_test.go` - -- [ ] **Step 1: Write the file** - -```go -//go:build integration - -package api_test - -import ( - "net/http" - "sync" - "testing" - - "github.com/RandomCodeSpace/docsiq/internal/api/itest" -) - -func TestAuth_BearerRequiredOnAPI(t *testing.T) { - e := itest.New(t) - - resp, err := e.Server.Client().Get(e.Server.URL + "/api/stats?project=_default") - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusUnauthorized { - t.Errorf("no-auth GET /api/stats status=%d want 401", resp.StatusCode) - } -} - -func TestAuth_BearerNotRequiredOnHealth(t *testing.T) { - e := itest.New(t) - resp, err := e.Server.Client().Get(e.Server.URL + "/health") - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != 200 { - t.Errorf("no-auth /health status=%d want 200", resp.StatusCode) - } -} - -func TestAuth_OptionsBypasses(t *testing.T) { - e := itest.New(t) - req, _ := http.NewRequest(http.MethodOptions, e.Server.URL+"/api/stats", nil) - resp, err := e.Server.Client().Do(req) - if err != nil { - t.Fatal(err) - } - if resp.StatusCode == http.StatusUnauthorized { - t.Errorf("OPTIONS got 401; should bypass auth") - } -} - -func TestAuth_ConcurrentFailuresNoRace(t *testing.T) { - e := itest.New(t) - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - resp, _ := e.Server.Client().Get(e.Server.URL + "/api/stats?project=_default") - if resp != nil { - resp.Body.Close() - } - }() - } - wg.Wait() - // Pass = no data race detected by -race flag -} -``` - -- [ ] **Step 2: Run** - -```bash -CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -run 'TestAuth' ./internal/api/... -``` -Expected: 4 tests PASS. Race detector clean. - -- [ ] **Step 3: Commit** - -```bash -git add internal/api/auth_integration_test.go -git commit -m "test: auth integration suite" -``` - ---- - -### Task C3: project_integration_test.go, notes_integration_test.go, docs_integration_test.go - -Pattern (per file): - -- [ ] **Step 1: Write test file** covering suite scope from spec §5 table. - -For `project_integration_test.go`, the required cases: -- `?project=` scopes isolation end-to-end: write note A into `foo`, read as `bar` → 404 -- `_default` auto-registers on first request -- Unknown project with valid slug → 404 on read - -For `notes_integration_test.go`: -- PUT→GET→DELETE round-trip (status 200 / 200 / 204) -- Wikilink graph updates on write: PUT note with `[[target]]`, GET graph shows edge -- FTS5 search finds new note by token in body -- Tar export/import round-trip preserves file tree + content - -For `docs_integration_test.go`: -- upload → index → search with FakeProvider -- doc uploaded to project A not visible in project B's search - -- [ ] **Step 2: Run each suite with `-race`**, 0 FAIL expected. - -- [ ] **Step 3: Commit each file separately** with message `test: <suite name> integration suite`. - ---- - -### Task C4: mcp_integration_test.go - -**Files:** -- Create: `internal/mcp/mcp_integration_test.go` - -- [ ] **Step 1: Write MCP round-trip tests** - -```go -//go:build integration - -package mcp_test - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/RandomCodeSpace/docsiq/internal/api/itest" -) - -type rpcRequest struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Method string `json:"method"` - Params map[string]any `json:"params"` -} - -type rpcResponse struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Result json.RawMessage `json:"result"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -func callTool(t *testing.T, e *itest.Env, tool string, args map[string]any) rpcResponse { - t.Helper() - payload := rpcRequest{ - JSONRPC: "2.0", ID: 1, - Method: "tools/call", - Params: map[string]any{"name": tool, "arguments": args}, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", e.Server.URL+"/mcp", bytes.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+e.APIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := e.Client.Do(req) - if err != nil { - t.Fatalf("MCP %s: %v", tool, err) - } - defer resp.Body.Close() - var r rpcResponse - raw, _ := io.ReadAll(resp.Body) - if err := json.Unmarshal(raw, &r); err != nil { - t.Fatalf("parse MCP response: %v body=%s", err, raw) - } - if r.Error != nil { - t.Fatalf("MCP %s error: %s", tool, r.Error.Message) - } - return r -} - -func TestMCP_WriteAndSearchNoteRoundTrip(t *testing.T) { - e := itest.New(t) - // Auto-register _default - e.GET(t, "/api/projects") - - _ = callTool(t, e, "write_note", map[string]any{ - "project": "_default", - "key": "mcp-smoke", - "content": "# hello\n\nMCP can [[write]] notes.", - "tags": []string{"mcp"}, - }) - - hits := callTool(t, e, "search_notes", map[string]any{ - "project": "_default", - "query": "hello", - }) - if len(hits.Result) == 0 { - t.Fatalf("search_notes empty result") - } -} - -func TestMCP_ListProjectsReturnsDefault(t *testing.T) { - e := itest.New(t) - e.GET(t, "/api/projects") - r := callTool(t, e, "list_projects", map[string]any{}) - if !bytes.Contains(r.Result, []byte("_default")) { - t.Errorf("list_projects result missing _default: %s", r.Result) - } -} - -func TestMCP_StatsToolWorks(t *testing.T) { - e := itest.New(t) - e.GET(t, "/api/projects") - r := callTool(t, e, "stats", map[string]any{"project": "_default"}) - if len(r.Result) == 0 { - t.Fatal("stats empty") - } -} -``` - -- [ ] **Step 2: Run** - -```bash -CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -run 'TestMCP' ./internal/mcp/... -``` -Expected: 3 tests PASS. If the MCP tools/call JSON shape differs in practice, adjust `rpcRequest` / result parsing. - -- [ ] **Step 3: Commit** - -```bash -git add internal/mcp/mcp_integration_test.go -git commit -m "test: MCP JSON-RPC integration suite" -``` - ---- - -### Task C5: concurrency_integration_test.go - -**Files:** -- Create: `internal/api/concurrency_integration_test.go` - -- [ ] **Step 1: Write** - -```go -//go:build integration - -package api_test - -import ( - "fmt" - "sync" - "testing" - - "github.com/RandomCodeSpace/docsiq/internal/api/itest" -) - -func TestConcurrency_100ParallelNotePUTsSameProject(t *testing.T) { - e := itest.New(t) - e.GET(t, "/api/projects") // auto-register _default - - const N = 100 - var wg sync.WaitGroup - errs := make(chan error, N) - - for i := 0; i < N; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - resp := e.PUTNote(t, "_default", fmt.Sprintf("concurrent/n-%03d", i), - fmt.Sprintf("# Note %d\n\nbody\n", i), []string{"c"}) - if resp.StatusCode != 200 { - errs <- fmt.Errorf("PUT %d: status %d", i, resp.StatusCode) - } - resp.Body.Close() - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - t.Error(err) - } -} - -func TestConcurrency_50ReadsDuringWrites(t *testing.T) { - e := itest.New(t) - e.GET(t, "/api/projects") - e.PUTNote(t, "_default", "hot", "# hot\n", nil).Body.Close() - - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(2) - go func(i int) { - defer wg.Done() - e.PUTNote(t, "_default", "hot", fmt.Sprintf("# hot v%d\n", i), nil).Body.Close() - }(i) - go func() { - defer wg.Done() - resp, _ := e.GET(t, "/api/projects/_default/notes/hot") - _ = resp - }() - } - wg.Wait() -} -``` - -- [ ] **Step 2: Run with -race** -```bash -CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -run 'TestConcurrency' ./internal/api/... -``` -Expected: PASS. Any race detector output = P0 finding. - -- [ ] **Step 3: Commit** - ---- - -### Task C6: history_integration_test.go, hooks_integration_test.go, shutdown_integration_test.go, metrics_integration_test.go - -Each suite follows the same structure as C2 (auth): `package <pkg>_test`, `//go:build integration` header, one top-level `Test<Suite>_<Case>` per required case, uses `itest.New(t)` from the harness, assertions as described below. - -For each file: -- [ ] **Step 1: Write suite per spec §5 scope** using the template pattern from C2/C4/C5 (build-tag header, package name, harness import, per-case `Test*` functions). Each required case below corresponds to one `Test*` function. -- [ ] **Step 2: Run with `-race -tags "sqlite_fts5 integration"`** -- [ ] **Step 3: Commit** - -`history_integration_test.go`: -- write same key twice → history endpoint returns 2 entries in reverse-chrono order -- delete creates a commit with "remove:" message -- set PATH="" before request → write still 200 (graceful git-missing fallback) - -`hooks_integration_test.go`: -- POST /api/hook/SessionStart with a registered remote → 200 + `{project, additionalContext}` -- POST with unknown remote → 204 -- POST with malformed JSON → 400 - -`shutdown_integration_test.go`: -- `defer goleak.VerifyNone(t)` at test start -- fire 10 requests, call `srv.Close()`, assert no goroutines leaked -- import `go.uber.org/goleak` — guard against known stdlib leaks with `goleak.IgnoreCurrent()` - -`metrics_integration_test.go`: -- GET /metrics → 200 -- body matches `^docsiq_\w+\s+\d` on at least 3 lines (at least 3 metrics emitted) -- fire N requests to /health, scrape /metrics, verify `docsiq_requests_total{path="/health"}` incremented - ---- - -### Task C7: Integration job in ci.yml - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] **Step 1: Add integration-test job** - -In `ci.yml`, add to the `test` job's matrix OR add a new `test-integration` job: - -```yaml - test-integration: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: { go-version: '1.22' } - - name: cache go build - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: go-integ-${{ hashFiles('go.sum') }} - - name: integration tests - run: CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -timeout 600s ./... -``` - -- [ ] **Step 2: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: run integration tests with -race on Linux" -``` - ---- - -## Wave D — Hooks + MCP + Docs - -### Task D1: Hook schema verification - -**Files:** -- Modify: `internal/hookinstaller/{claude,cursor,copilot,codex}.go` -- Create: `internal/hookinstaller/fixtures/<client>/{before,after}.json` per client -- Modify: `README.md` (add support matrix) -- Create: `internal/hookinstaller/<client>_fixture_test.go` per client - -- [ ] **Step 1: Read current installer code** - -```bash -cat internal/hookinstaller/claude.go -cat internal/hookinstaller/cursor.go -cat internal/hookinstaller/copilot.go -cat internal/hookinstaller/codex.go -``` -Record the JSON shape each installer produces. - -- [ ] **Step 2: Fetch authoritative docs for each client** - -Use `ctx_fetch_and_index` or `WebFetch` against: -- Claude Code: `https://docs.claude.com/en/docs/claude-code/hooks` -- Cursor: `https://docs.cursor.com/` (search "hooks" / "mcp" / "context") -- GitHub Copilot CLI: `https://docs.github.com/en/copilot/` + `https://github.com/github/gh-copilot` -- OpenAI Codex CLI: `https://github.com/openai/codex` (README + config docs) - -For each, record the verified hook schema (or note "no documented API"). - -- [ ] **Step 3: Update installers** - -For each client: -- If the docs confirm the current schema → no source change, just add fixture. -- If the docs show a different schema → update the Go code and add a `// schema source: <URL> fetched 2026-04-17` comment above the merge logic. -- If the client has no documented hook API: - - Add header comment: `// UNVERIFIED — <client> does not publicly document a SessionStart hook API as of 2026-04-17.` - - In `Install()`, emit `slog.Warn("⚠️ installing unverified hook for <client>", "client", "<client>")`. - -- [ ] **Step 4: Add fixtures** - -For each client, create `internal/hookinstaller/fixtures/<client>/before.json` (a realistic pre-existing config with unrelated entries) and `after.json` (what it should look like after Install() with docsiq hook = `/path/to/hook.sh`). - -Example (`claude/before.json`): -```json -{ - "hooks": { - "PreToolUse": [{"type": "command", "command": "/other/script.sh"}] - }, - "theme": "dark" -} -``` - -Example (`claude/after.json`): -```json -{ - "hooks": { - "PreToolUse": [{"type": "command", "command": "/other/script.sh"}], - "SessionStart": [{"type": "command", "command": "/path/to/hook.sh"}] - }, - "theme": "dark" -} -``` - -- [ ] **Step 5: Write fixture tests** - -`internal/hookinstaller/claude_fixture_test.go`: -```go -package hookinstaller - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestClaude_FixtureTransform(t *testing.T) { - tmp := t.TempDir() - before, err := os.ReadFile(filepath.Join("fixtures", "claude", "before.json")) - if err != nil { t.Fatal(err) } - cfg := filepath.Join(tmp, "settings.json") - if err := os.WriteFile(cfg, before, 0o644); err != nil { t.Fatal(err) } - - inst := &ClaudeInstaller{configPath: cfg} - if err := inst.Install("/path/to/hook.sh"); err != nil { t.Fatal(err) } - - got, _ := os.ReadFile(cfg) - want, _ := os.ReadFile(filepath.Join("fixtures", "claude", "after.json")) - - var gotMap, wantMap map[string]any - _ = json.Unmarshal(got, &gotMap) - _ = json.Unmarshal(want, &wantMap) - if !jsonEqual(gotMap, wantMap) { - t.Errorf("mismatch:\n got=%s\nwant=%s", got, want) - } -} - -func jsonEqual(a, b any) bool { - ab, _ := json.Marshal(a) - bb, _ := json.Marshal(b) - return string(ab) == string(bb) -} -``` - -Repeat for each client. - -- [ ] **Step 6: Add support matrix to README.md** - -In `README.md`, add a section: -```markdown -## Hook support matrix - -| Client | Config path | Schema source | Status | -|---|---|---|---| -| Claude Code | `~/.claude/settings.json` | [docs.claude.com/en/docs/claude-code/hooks](https://docs.claude.com/en/docs/claude-code/hooks) | ✅ verified | -| Cursor | `~/.cursor/<file>` | <URL or "none"> | ✅ verified / ⚠ unverified | -| Copilot CLI | `~/.config/github-copilot/<file>` | <URL or "none"> | ✅ / ⚠ | -| Codex CLI | `~/.codex/<file>` | <URL or "none"> | ✅ / ⚠ | -``` - -Fill in the actual URLs/status per the research. - -- [ ] **Step 7: Run** - -```bash -CGO_ENABLED=1 go test -tags sqlite_fts5 ./internal/hookinstaller/... -``` -Expected: all PASS. - -- [ ] **Step 8: Commit per client** - -```bash -git add internal/hookinstaller/claude.go internal/hookinstaller/fixtures/claude/ internal/hookinstaller/claude_fixture_test.go -git commit -m "feat(hooks): verified Claude Code schema + fixture tests" -``` -Repeat for cursor, copilot, codex. Then final commit for README matrix. - ---- - -### Task D2: MCP global_search per-project LLM - -**Files:** -- Modify: `internal/mcp/tools.go` (the `global_search` handler) - -- [ ] **Step 1: Write failing test** - -Add to `internal/mcp/mcp_integration_test.go`: -```go -func TestMCP_GlobalSearchUsesPerProjectProvider(t *testing.T) { - // Two projects, different provider overrides, assert search uses the - // project-specific one. - // Setup requires config with LLMOverrides map populated. - t.Skip("pending llm.ProviderForProject wiring — see Task D2") -} -``` -(Swap to real assertions after Step 2.) - -- [ ] **Step 2: Thread project arg** - -In `internal/mcp/tools.go`, find the `global_search` registration. Update the handler: -```go -func (s *Server) globalSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := req.GetArguments() - slug := projectSlugArg(args) // existing helper; or add one - query := stringArg(args, "query", "") - topK := intArg(args, "top_k", 5) - - st, err := s.stores.ForProject(slug) - if err != nil { - return toolError(err), nil - } - prov := llm.ProviderForProject(s.cfg, slug) - if prov == nil { - prov = s.provider - } - hits, err := search.GlobalSearch(ctx, st, prov, s.embedder, query, topK) - // ... rest unchanged -} -``` - -- [ ] **Step 3: Un-skip test, make it assert the provider name differs** - -- [ ] **Step 4: Run** -```bash -CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -run 'TestMCP_GlobalSearchUsesPerProjectProvider' ./internal/mcp/... -``` -Expected: PASS. - -- [ ] **Step 5: Commit** - ---- - -### Task D3: Content cleanup - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `/home/dev/projects/docsiq/UNIFICATION-PLAN.md` (parent dir) -- Create: `/home/dev/projects/docsiq/kgraph/ARCHIVED.md` - -- [ ] **Step 1: Rebrand CLAUDE.md** - -```bash -sed -i \ - -e 's|DocsContext|docsiq|g' \ - -e 's|docscontext|docsiq|g' \ - -e 's|~/\.docscontext/|~/.docsiq/|g' \ - -e 's|DOCSCONTEXT_|DOCSIQ_|g' \ - CLAUDE.md -``` -Then manually review the file; drop the "Recent Changes (already committed)" section entirely (it's superseded). - -- [ ] **Step 2: Rebrand UNIFICATION-PLAN.md** - -Apply the same sed. Add at the very top (after the H1 heading): -```markdown -> **STATUS: Implemented.** This plan was executed 2026-04-17 across commits `3d2d2ce..790810f` (and subsequent quality-sweep work). Retained for historical reference. -``` - -- [ ] **Step 3: Create kgraph/ARCHIVED.md** - -`/home/dev/projects/docsiq/kgraph/ARCHIVED.md`: -```markdown -# Archived - -This TypeScript codebase was ported into Go as **docsiq** on 2026-04-17. - -- New home: `github.com/RandomCodeSpace/docsiq` (née `docscontext`) -- Port commits: `3d2d2ce..790810f` on `main` of the docsiq repo -- Spec: `docs/superpowers/specs/2026-04-17-quality-sweep-design.md` in docsiq - -This repo is retained for historical reference only. No further -development happens here. -``` - -- [ ] **Step 4: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: rebrand CLAUDE.md to docsiq; drop superseded sections" -``` -The UNIFICATION-PLAN.md and kgraph/ARCHIVED.md live outside this repo — do NOT `git add` them here. Commit them separately in their own contexts (or skip if those dirs are untracked). - ---- - -### Task D4: User-facing docs directory - -**Files:** -- Create 8 files under `docs/` - -- [ ] **Step 1: Create `docs/README.md`** - -```markdown -# docsiq Documentation - -- [Getting Started](./getting-started.md) -- [CLI Reference](./cli-reference.md) -- [MCP Tools](./mcp-tools.md) -- [REST API](./rest-api.md) -- [Configuration](./config.md) -- [Hooks](./hooks.md) -- [Architecture](./architecture.md) -``` - -- [ ] **Step 2: Create `docs/getting-started.md`** with sections: Prerequisites (Go 1.22+, gcc/Xcode CLT), Install (`go install github.com/RandomCodeSpace/docsiq@latest`), First project (`docsiq init` in a git repo), Start server (`docsiq serve`), Check it works (`curl localhost:8080/health`). - -- [ ] **Step 3: Create `docs/cli-reference.md`** — one section per command (`init`, `serve`, `index`, `stats`, `projects`, `hooks`, `vec`, `version`) with flags + examples. Each command's section generated by running `./docsiq <cmd> --help` and reformatting. - -- [ ] **Step 4: Create `docs/mcp-tools.md`** — all 19 MCP tools. Use this template per tool: -```markdown -### `tool_name` - -<one-sentence description> - -**Arguments:** -| name | type | required | description | - -**Returns:** <JSON shape> - -**Example:** `<MCP call example>` -``` -Scrape from `internal/mcp/tools.go` + `internal/mcp/notes_tools.go`. - -- [ ] **Step 5: Create `docs/rest-api.md`** — every REST endpoint. Template: -```markdown -### `METHOD /path` - -<description> - -**Auth:** Bearer | public - -**Query params:** ... - -**Request body:** (JSON shape) - -**Response:** (status → body shape) -``` -Scrape from `internal/api/router.go` + handler implementations. - -- [ ] **Step 6: Create `docs/config.md`** — every config field (from `internal/config/config.go` struct) with env var name, default, type, description. - -- [ ] **Step 7: Create `docs/hooks.md`** — duplicate + expand the support matrix from README.md; add troubleshooting section ("My hook doesn't fire" → check install status, server reachable, etc.). - -- [ ] **Step 8: Create `docs/architecture.md`** — one diagram (ASCII or Mermaid), 2-page explanation of per-project layout, store + registry + HNSW flow. - -- [ ] **Step 9: Commit** - -```bash -git add docs/ -git commit -m "docs: user-facing guides (CLI, MCP, REST, config, hooks, arch)" -``` - ---- - -## Wave E — Review Remediation - -### Task E1: Read REVIEW.md, categorize findings - -- [ ] **Step 1: Count findings** - -```bash -grep -c '^### \[P0-' REVIEW.md -grep -c '^### \[P1-' REVIEW.md -grep -c '^### \[P2-' REVIEW.md -``` - -- [ ] **Step 2: If P0+P1 > 20 findings, PAUSE and escalate to user** (per spec §10 Risks). Post summary + recommended path (break into sub-plan?). - -- [ ] **Step 3: If P0+P1 ≤ 20, proceed to E2.** - -### Task E2: Fix each P0 finding (one commit per finding) - -Pattern per P0: - -- [ ] **Step 1: Read the finding** in REVIEW.md (`### [P0-<N>]`). -- [ ] **Step 2: Write the failing regression test** at the file:line indicated. -- [ ] **Step 3: Run the test; verify it fails.** -- [ ] **Step 4: Implement the recommended fix** (or alternative with equal correctness). -- [ ] **Step 5: Run the test; verify it passes.** -- [ ] **Step 6: Run full test suite** — `make test && make test-integration`. All green. -- [ ] **Step 7: Update REVIEW.md** — append under the finding: `**Status:** fixed in <commit-sha-short>`. -- [ ] **Step 8: Commit** - ```bash - git commit -m "fix: <short description> (P0-<N> from REVIEW)" - ``` - -### Task E3: Fix P1 findings (group thematically, one commit per theme) - -Same pattern as E2, grouped by theme (e.g., "fix all auth middleware edge cases in one commit"). - -### Task E4: Comment P2 findings - -- [ ] **Step 1: For each P2 finding**, add a `// TODO(docsiq): P2-<N> <short summary>` comment at the file:line. -- [ ] **Step 2: Update REVIEW.md**: `**Status:** deferred (P2), TODO planted at <file>:<line>`. -- [ ] **Step 3: Commit all P2 TODOs together** - ```bash - git commit -m "docs: plant TODO markers for deferred P2 review findings" - ``` - -### Task E5: Assert final REVIEW.md state - -- [ ] **Step 1: Verify no unresolved P0/P1** - ```bash - awk '/^## P[01] —/,/^## /' REVIEW.md | grep -c '\*\*Status:\*\* fixed' - awk '/^## P[01] —/,/^## /' REVIEW.md | grep -c '^### \[P[01]-' - ``` - The two counts must match (every P0 and P1 has a Status: fixed or disputed line). - -- [ ] **Step 2: Update summary counts at top of REVIEW.md** to reflect resolved vs. outstanding. - -- [ ] **Step 3: Commit** - ```bash - git add REVIEW.md - git commit -m "docs: REVIEW.md — all P0/P1 findings resolved" - ``` - ---- - -## Wave F — Lint Modernization Sweep - -### Task F1: Sweep Go 1.24 style hints - -**Files:** any Go file flagged by `go vet` with modernization hints. - -- [ ] **Step 1: Run `gopls` modernize check** - -```bash -go install golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest 2>/dev/null || true -modernize -test ./... 2>&1 | head -50 -``` -If modernize binary isn't available, skip it and rely on the diagnostics already in the LSP output (the many `rangeint`, `stringsseq`, `mapsloop`, `b.Loop()`, `any`, `minmax` hints we've seen throughout). - -- [ ] **Step 2: Apply modernizations, one transformation per commit** - -Example commit shapes: -```bash -# Apply rangeint across all test files -git commit -m "style: modernize for-loops to range-over-int" -``` - -Transformations to apply: -- `for i := 0; i < n; i++` → `for i := range n` -- `for _, x := range strings.Split(s, sep)` → `for _, x := range strings.SplitSeq(s, sep)` -- manual copy loop `for k,v := range src { dst[k] = v }` → `maps.Copy(dst, src)` -- `for i := 0; i < b.N; i++` → `for b.Loop()` -- `interface{}` → `any` -- Delete unused `func min(...)`/`func max(...)` in favor of builtins - -- [ ] **Step 3: After each transformation, run `make vet && make test`** - -Expected: exit 0 after each. Any regression = revert and diagnose. - -### Task F2: False-positive `unusedfunc` suppressions - -- [ ] **Step 1: Find unused-by-linter functions that are actually used via composition** - -Likely culprits: -- `bearerAuthMiddleware` (referenced in `NewRouter` return chain) -- `h.health` (registered by method reference in `mux.HandleFunc`) -- `registerHookRoutes` (called in `NewRouter`) -- Various `h.foo` methods referenced via `mux.HandleFunc` - -- [ ] **Step 2: Add `//nolint:unusedfunc` directive** above each genuinely-used-but-flagged function. - -- [ ] **Step 3: Commit** - -```bash -git commit -m "style: silence unusedfunc false positives for method dispatch" -``` - ---- - -## Final Verification - -### Task V1: Local gates - -- [ ] **Step 1: Build** - ```bash - CGO_ENABLED=1 go build -tags sqlite_fts5 -o docsiq ./ - ``` - Expected: exit 0, `./docsiq` exists. - -- [ ] **Step 2: Vet** - ```bash - make vet - ``` - Expected: exit 0. - -- [ ] **Step 3: Unit tests** - ```bash - make test - ``` - Expected: all packages ok. - -- [ ] **Step 4: Integration tests** - ```bash - make test-integration - ``` - Expected: all green with `-race`. - -- [ ] **Step 5: Frontend tests + coverage** - ```bash - cd ui && npm run test:coverage - ``` - Expected: all green; coverage ≥ 70% statements, ≥ 60% branches on notes/nav/shared/hooks. - -- [ ] **Step 6: UI build** - ```bash - npm --prefix ui run build - git diff --exit-code -- ui/dist/ - ``` - Expected: clean (or commit fresh dist). - -- [ ] **Step 7: Grep audits** - ```bash - grep -r 'docscontext' internal/ cmd/ ; echo "should be 0 hits" - grep -r 'TODO(docsiq): P[01]' internal/ cmd/ ; echo "should be 0 hits" - ``` - Expected: both 0 hits. - -### Task V2: End-to-end smoke - -- [ ] **Step 1: Isolated serve + full flow** - -```bash -tmp=$(mktemp -d) -DOCSIQ_DATA_DIR="$tmp/data" \ -DOCSIQ_SERVER_PORT=18888 \ -DOCSIQ_DEFAULT_PROJECT=_default \ -./docsiq serve >"$tmp/server.log" 2>&1 & -pid=$! -sleep 2 - -curl -sf http://127.0.0.1:18888/health -curl -sf http://127.0.0.1:18888/metrics | head -5 -curl -sf "http://127.0.0.1:18888/api/projects" -H "Authorization: Bearer ${DOCSIQ_API_KEY:-}" - -kill $pid -tail -5 "$tmp/server.log" -``` -Expected: health=200, metrics=prom-format, projects=JSON with `_default`, no goroutine leak warnings in log tail. - -### Task V3: Push - -- [ ] **Step 1: Verify branch clean** - ```bash - git status --short - ``` - Expected: clean. - -- [ ] **Step 2: Push** - ```bash - git push origin main - ``` - -- [ ] **Step 3: Watch CI** - Monitor the GitHub Actions run from the push. If any job fails, file as follow-up in REVIEW.md (post-sweep) and fix — do NOT merge fails. - ---- - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-04-17-quality-sweep.md`. Two execution options: - -**1. Subagent-Driven (recommended)** — Dispatch fresh subagent per task, review between tasks, fast iteration. Uses `superpowers:subagent-driven-development`. - -**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. - -Which approach? diff --git a/docs/superpowers/plans/2026-04-18-ui-redesign.md b/docs/superpowers/plans/2026-04-18-ui-redesign.md deleted file mode 100644 index 81696eb..0000000 --- a/docs/superpowers/plans/2026-04-18-ui-redesign.md +++ /dev/null @@ -1,4060 +0,0 @@ -# docsiq UI Redesign — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Greenfield rewrite of the docsiq web UI per `docs/superpowers/specs/2026-04-18-ui-redesign-design.md`. Ship a keyboard-first, responsive, WCAG-AA, reduced-motion-aware SPA that replaces the current hand-rolled React+CSS UI without any backend regression. - -**Architecture:** React 19 + Vite 6 + Tailwind 4 + shadcn/ui primitives, TanStack Query for server state, Zustand for client state, React Router 6 for routing, markdown-it + shiki for note rendering, d3-force for graph layout, Framer Motion gated by `prefers-reduced-motion`. Fonts (Geist Sans + Geist Mono) self-hosted. One shared bearer token injected into `<meta>` tag by the Go SPA handler. - -**Tech Stack:** React 19, Vite 6, TypeScript 5.7, Tailwind 4, shadcn/ui, Radix, lucide-react, TanStack Query v5, Zustand v5, React Router 6, React Hook Form 7, Zod 3, Framer Motion 11, markdown-it, shiki, d3-force, CodeMirror 6, cmdk, Vitest 3, @testing-library/react 16, MSW 2. - -**Spec:** `docs/superpowers/specs/2026-04-18-ui-redesign-design.md` - ---- - -## File Structure (final state) - -``` -ui/ -├── src/ -│ ├── main.tsx -│ ├── App.tsx -│ ├── routes/ -│ │ ├── Home.tsx -│ │ ├── notes/{NotesLayout,NoteView,NoteEditor,NotesSearch}.tsx -│ │ ├── documents/{DocumentsList,DocumentView,UploadModal}.tsx -│ │ ├── Graph.tsx -│ │ └── MCPConsole.tsx -│ ├── components/ -│ │ ├── ui/ — shadcn primitives (Button, Dialog, Input, etc.) -│ │ ├── layout/{Shell,Sidebar,TopBar,StatsStrip,SkipLink}.tsx -│ │ ├── command/{CommandPalette,ResultRow}.tsx -│ │ ├── activity/{ActivityFeed,EventRow,EventBadge}.tsx -│ │ ├── graph/{GraphCanvas,GlanceView,GraphFilters}.tsx -│ │ ├── notes/{MarkdownView,WikiLink,LinkPanel,TreeDrawer}.tsx -│ │ ├── docs/{DocTable,DocOutline}.tsx -│ │ └── common/{Toast,Skeleton,EmptyState,ErrorBoundary,Kbd}.tsx -│ ├── hooks/ -│ │ ├── api/{useStats,useActivity,useNotes,useDocs,useGraph,useMCP,useCommand}.ts -│ │ ├── useHotkey.ts -│ │ ├── useLastVisit.ts -│ │ └── useReducedMotion.ts -│ ├── stores/{ui,project,toast}.ts -│ ├── lib/{api-client,markdown,graph-layout,utils,format,i18n}.ts -│ ├── i18n/en.ts -│ ├── styles/globals.css -│ └── types/api.ts -├── public/fonts/{Geist-400,Geist-500,Geist-600,GeistMono-400,GeistMono-500}.woff2 -├── tailwind.config.ts -├── vitest.config.ts -├── tsconfig.{json,app.json,node.json} -├── vite.config.ts -├── index.html -├── embed.go — unchanged -└── package.json — name: docsiq-ui - -internal/api/router.go — +8 lines (meta-tag injection, Phase 10) -``` - ---- - -## Wave 0 — Foundation - -### Task 0.1: Create feature branch - -- [ ] **Step 1: Branch from main** - ```bash - cd /home/dev/projects/docsiq - git checkout -b ui-redesign - git push -u origin ui-redesign - ``` - -- [ ] **Step 2: Confirm clean starting state** - ```bash - git status --short; echo "---"; make test 2>&1 | grep -E '^(ok|FAIL)' | head -5 - ``` - Expected: zero lines in `git status`, all packages `ok`. - -### Task 0.2: Wipe old ui/src and ui-root cruft - -**Files:** -- Delete: `ui/src/**`, `ui/app.js`, `ui/graph.js`, `ui/style.css`, `ui/vendor/` - -- [ ] **Step 1: Delete the old UI source** - ```bash - cd /home/dev/projects/docsiq/ui - rm -rf src app.js graph.js style.css vendor - ``` - -- [ ] **Step 2: Verify only config + embed remain** - ```bash - ls -la - ``` - Expected dirs: `public/`, `dist/`, `node_modules/` (if present). Expected files: `embed.go`, `index.html`, `package.json`, `package-lock.json`, `tsconfig*.json`, `vite.config.ts`, `vitest.config.ts`. - -- [ ] **Step 3: Commit the clean slate** - ```bash - cd /home/dev/projects/docsiq - git add -A - git commit -m "chore(ui): wipe pre-redesign src and legacy root cruft" - ``` - -### Task 0.3: Placeholder `ui/dist/` so backend builds stay green - -**Files:** -- Create: `ui/dist/index.html` - -- [ ] **Step 1: Write placeholder** - ```bash - cat > ui/dist/index.html <<'EOF' - <!doctype html> - <html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <title>docsiq — UI in progress - - - -
-

docsiq

-

UI redesign in progress. API + MCP paths unchanged.

-
- - - EOF - ``` - -- [ ] **Step 2: Verify Go build still succeeds** - ```bash - CGO_ENABLED=1 go build -tags sqlite_fts5 -o docsiq ./ - ``` - Expected: exit 0. - -- [ ] **Step 3: Commit** - ```bash - git add ui/dist/index.html - git commit -m "chore(ui): placeholder dist/index.html during rewrite" - ``` - -### Task 0.4: Rewrite `ui/package.json` - -**Files:** -- Modify: `ui/package.json` (full rewrite) - -- [ ] **Step 1: Replace `ui/package.json` contents** - ```json - { - "name": "docsiq-ui", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b --noEmit && vite build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "typecheck": "tsc -b --noEmit", - "lint": "eslint src" - }, - "dependencies": { - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", - "@radix-ui/react-popover": "^1.1.4", - "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.4", - "@tanstack/react-query": "^5.62.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.4", - "codemirror": "^6.0.1", - "@codemirror/lang-markdown": "^6.3.1", - "@codemirror/view": "^6.35.0", - "@codemirror/state": "^6.5.0", - "@codemirror/commands": "^6.7.0", - "d3-force": "^3.0.0", - "framer-motion": "^11.15.0", - "lucide-react": "^0.469.0", - "markdown-it": "^14.1.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.0", - "react-router-dom": "^6.28.0", - "shiki": "^1.24.0", - "tailwind-merge": "^2.5.5", - "zod": "^3.24.0", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/d3-force": "^3.0.10", - "@types/markdown-it": "^14.1.2", - "@types/node": "^22.10.0", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^3.2.4", - "axe-core": "^4.10.2", - "eslint": "^9.17.0", - "jsdom": "^25.0.1", - "msw": "^2.7.0", - "tailwindcss": "^4.0.0", - "typescript": "~5.7.2", - "typescript-eslint": "^8.18.0", - "vite": "^6.0.0", - "vitest": "^3.2.4" - } - } - ``` - -- [ ] **Step 2: Install** - ```bash - cd ui && rm -f package-lock.json && npm install - ``` - -- [ ] **Step 3: Security audit** - ```bash - npm audit --audit-level=moderate - ``` - Expected: `found 0 vulnerabilities` (or only transitive that can't be patched — document if any). If High/Critical: STOP and escalate. - -- [ ] **Step 4: Commit** - ```bash - cd .. - git add ui/package.json ui/package-lock.json - git commit -m "feat(ui): new stack — React 19 + Vite 6 + Tailwind 4 + shadcn/ui" - ``` - -### Task 0.5: Vite + Tailwind 4 config - -**Files:** -- Modify: `ui/vite.config.ts` -- Modify: `ui/tsconfig.json`, `ui/tsconfig.app.json`, `ui/tsconfig.node.json` -- Create: `ui/src/styles/globals.css` - -- [ ] **Step 1: Replace `ui/vite.config.ts`** - ```ts - import { defineConfig } from "vite"; - import react from "@vitejs/plugin-react"; - import tailwind from "@tailwindcss/vite"; - import { fileURLToPath, URL } from "node:url"; - - export default defineConfig({ - plugins: [react(), tailwind()], - resolve: { - alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)), - }, - }, - build: { - outDir: "dist", - emptyOutDir: true, - sourcemap: false, - rollupOptions: { - output: { - manualChunks: { - "markdown": ["markdown-it", "shiki"], - "graph": ["d3-force"], - "editor": ["codemirror", "@codemirror/view", "@codemirror/state", "@codemirror/commands", "@codemirror/lang-markdown"], - }, - }, - }, - }, - server: { - proxy: { - "/api": "http://localhost:8080", - "/mcp": "http://localhost:8080", - "/health": "http://localhost:8080", - "/metrics": "http://localhost:8080", - }, - }, - }); - ``` - -- [ ] **Step 2: Replace `ui/tsconfig.json`** - ```json - { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ], - "compilerOptions": { - "baseUrl": ".", - "paths": { "@/*": ["src/*"] } - } - } - ``` - -- [ ] **Step 3: Replace `ui/tsconfig.app.json`** - ```json - { - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "baseUrl": ".", - "paths": { "@/*": ["src/*"] } - }, - "include": ["src"] - } - ``` - -- [ ] **Step 4: Replace `ui/tsconfig.node.json`** - ```json - { - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts"] - } - ``` - -- [ ] **Step 5: Create `ui/src/styles/globals.css`** (tokens + Tailwind 4 theme layer) - ```css - @import "tailwindcss"; - - @theme { - --color-base: #0f1115; - --color-surface-1: #14171d; - --color-surface-2: #1b1f26; - --color-border: #1e2128; - --color-border-strong: #2a2f38; - --color-text: #e4e6ec; - --color-text-muted: #6f7482; - --color-text-faint: #4a4f59; - - --color-accent: #3ecf8e; - --color-accent-hover: #4ad89a; - --color-accent-contrast: #0f1115; - - --color-semantic-new: #3ecf8e; - --color-semantic-index: #6ba6ff; - --color-semantic-graph: #b08fe8; - --color-semantic-error: #e06060; - --color-semantic-warn: #f3b54a; - - --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; - --font-mono: "Geist Mono", ui-monospace, monospace; - - --radius-sm: 4px; - --radius: 6px; - --radius-lg: 10px; - --radius-pill: 999px; - - --ease-out: cubic-bezier(0.3, 0, 0, 1); - --ease-in: cubic-bezier(0.7, 0, 1, 0.3); - } - - @media (prefers-color-scheme: light) { - @theme { - --color-base: #f8f9fb; - --color-surface-1: #ffffff; - --color-surface-2: #f1f3f6; - --color-border: #e5e8ec; - --color-border-strong: #c8ced6; - --color-text: #0f1115; - --color-text-muted: #5e6672; - --color-text-faint: #8c93a0; - --color-accent: #1faa69; - --color-accent-hover: #1a9a5f; - --color-accent-contrast: #ffffff; - --color-semantic-new: #1faa69; - --color-semantic-index: #2968d4; - --color-semantic-graph: #7246c2; - --color-semantic-error: #c03030; - --color-semantic-warn: #b8801e; - } - } - - @font-face { - font-family: "Geist"; - src: url("/fonts/Geist-400.woff2") format("woff2"); - font-weight: 400; - font-display: swap; - } - @font-face { - font-family: "Geist"; - src: url("/fonts/Geist-500.woff2") format("woff2"); - font-weight: 500; - font-display: swap; - } - @font-face { - font-family: "Geist"; - src: url("/fonts/Geist-600.woff2") format("woff2"); - font-weight: 600; - font-display: swap; - } - @font-face { - font-family: "Geist Mono"; - src: url("/fonts/GeistMono-400.woff2") format("woff2"); - font-weight: 400; - font-display: swap; - } - @font-face { - font-family: "Geist Mono"; - src: url("/fonts/GeistMono-500.woff2") format("woff2"); - font-weight: 500; - font-display: swap; - } - - html, body, #root { - height: 100%; - margin: 0; - background: var(--color-base); - color: var(--color-text); - font-family: var(--font-sans); - font-feature-settings: "cv11", "ss01", "ss03"; - -webkit-font-smoothing: antialiased; - } - - *:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; - border-radius: var(--radius-sm); - } - - @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.001ms !important; - scroll-behavior: auto !important; - } - } - ``` - -- [ ] **Step 6: Replace `ui/index.html`** - ```html - - - - - - - docsiq - - -
- - - - ``` - -- [ ] **Step 7: Smoke test typecheck** - ```bash - cd ui && npm run typecheck - ``` - Expected: exit 0 (no code yet — passes vacuously). - -- [ ] **Step 8: Commit** - ```bash - cd .. - git add ui/vite.config.ts ui/tsconfig.json ui/tsconfig.app.json ui/tsconfig.node.json ui/src/styles/globals.css ui/index.html - git commit -m "feat(ui): vite + tailwind4 + tokens + font-face scaffold" - ``` - -### Task 0.6: Vendor Geist fonts - -**Files:** -- Create: `ui/public/fonts/{Geist-400,Geist-500,Geist-600,GeistMono-400,GeistMono-500}.woff2` - -- [ ] **Step 1: Fetch Geist fonts locally** - ```bash - mkdir -p ui/public/fonts - cd ui/public/fonts - # Geist is Apache 2.0, hosted by Vercel. Download woff2 subsets. - curl -L -o Geist-400.woff2 https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-sans/Geist-Regular.woff2 - curl -L -o Geist-500.woff2 https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-sans/Geist-Medium.woff2 - curl -L -o Geist-600.woff2 https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-sans/Geist-SemiBold.woff2 - curl -L -o GeistMono-400.woff2 https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-mono/GeistMono-Regular.woff2 - curl -L -o GeistMono-500.woff2 https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-mono/GeistMono-Medium.woff2 - ls -la - ``` - Expected: 5 .woff2 files each 20-40 KB. - - If curl fails (air-gapped / CDN blocked): ask user to drop the 5 woff2 files into `ui/public/fonts/` manually. - -- [ ] **Step 2: Commit** - ```bash - cd /home/dev/projects/docsiq - git add ui/public/fonts/ - git commit -m "feat(ui): vendor Geist Sans + Mono fonts (Apache 2.0)" - ``` - -### Task 0.7: Vitest config + setup file - -**Files:** -- Modify: `ui/vitest.config.ts` -- Create: `ui/src/setupTests.ts` -- Create: `ui/src/test/msw.ts` -- Create: `ui/src/test/handlers.ts` - -- [ ] **Step 1: Replace `ui/vitest.config.ts`** - ```ts - import { defineConfig } from "vitest/config"; - import react from "@vitejs/plugin-react"; - import { fileURLToPath, URL } from "node:url"; - - export default defineConfig({ - plugins: [react()], - resolve: { - alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, - }, - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./src/setupTests.ts"], - coverage: { - reporter: ["text", "html"], - include: ["src/components/**", "src/hooks/**", "src/lib/**", "src/routes/**"], - exclude: ["src/test/**", "**/*.d.ts"], - thresholds: { statements: 70, branches: 60 }, - }, - }, - }); - ``` - -- [ ] **Step 2: Create `ui/src/setupTests.ts`** - ```ts - import "@testing-library/jest-dom"; - import { server } from "@/test/msw"; - import { beforeAll, afterEach, afterAll } from "vitest"; - - beforeAll(() => server.listen({ onUnhandledRequest: "error" })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - ``` - -- [ ] **Step 3: Create `ui/src/test/handlers.ts`** - ```ts - import { http, HttpResponse } from "msw"; - - export const handlers = [ - http.get("/api/stats", () => - HttpResponse.json({ - documents: 42, - chunks: 512, - entities: 380, - relationships: 820, - communities: 8, - notes: 17, - last_indexed: new Date().toISOString(), - }), - ), - http.get("/api/projects", () => HttpResponse.json([{ slug: "_default", name: "_default" }])), - ]; - ``` - -- [ ] **Step 4: Create `ui/src/test/msw.ts`** - ```ts - import { setupServer } from "msw/node"; - import { handlers } from "./handlers"; - - export const server = setupServer(...handlers); - ``` - -- [ ] **Step 5: Smoke test** - ```bash - cd ui && npm test 2>&1 | tail -6 - ``` - Expected: `No test files found` — vitest launches cleanly. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/vitest.config.ts ui/src/setupTests.ts ui/src/test/ - git commit -m "test(ui): vitest + MSW 2 scaffold" - ``` - -### Task 0.8: shadcn/ui initial primitives - -**Files:** -- Create: `ui/src/components/ui/{button,dialog,dropdown-menu,input,popover,scroll-area,separator,tooltip,command}.tsx` -- Create: `ui/src/lib/utils.ts` -- Create: `ui/components.json` (shadcn manifest) - -- [ ] **Step 1: Create `ui/components.json`** - ```json - { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/styles/globals.css", - "baseColor": "neutral", - "cssVariables": true - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui" - } - } - ``` - -- [ ] **Step 2: Create `ui/src/lib/utils.ts`** - ```ts - import { clsx, type ClassValue } from "clsx"; - import { twMerge } from "tailwind-merge"; - - export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); - } - ``` - -- [ ] **Step 3: Run shadcn add for the initial primitive set** - ```bash - cd ui && npx shadcn@latest add button dialog dropdown-menu input popover scroll-area separator tooltip command --yes - ``` - Expected: 9 files created under `src/components/ui/`. If the CLI prompts about Tailwind v4 compatibility, accept the new-york style. - -- [ ] **Step 4: Commit** - ```bash - cd .. - git add ui/components.json ui/src/lib/utils.ts ui/src/components/ui/ - git commit -m "feat(ui): shadcn/ui initial primitives" - ``` - -### Task 0.9: React entry + placeholder App - -**Files:** -- Create: `ui/src/main.tsx` -- Create: `ui/src/App.tsx` - -- [ ] **Step 1: Create `ui/src/main.tsx`** - ```tsx - import { StrictMode } from "react"; - import { createRoot } from "react-dom/client"; - import "./styles/globals.css"; - import App from "./App"; - - createRoot(document.getElementById("root")!).render( - - - , - ); - ``` - -- [ ] **Step 2: Create `ui/src/App.tsx` (placeholder — replaced in Wave 1)** - ```tsx - export default function App() { - return ( -
-
-

docsiq

-

wave-0 scaffold complete

-
-
- ); - } - ``` - -- [ ] **Step 3: Run a real build** - ```bash - cd ui && npm run build 2>&1 | tail -6 - ``` - Expected: exit 0; dist/ contains index.html + assets/*.js + assets/*.css. - -- [ ] **Step 4: Verify go:embed still picks it up** - ```bash - cd /home/dev/projects/docsiq && CGO_ENABLED=1 go build -tags sqlite_fts5 -o docsiq ./ && ls -la docsiq - ``` - Expected: binary built, size ~25-27 MB. - -- [ ] **Step 5: Commit** - ```bash - git add ui/src/main.tsx ui/src/App.tsx ui/dist/ - git commit -m "feat(ui): React entry + placeholder App" - ``` - ---- - -## Wave 1 — Layout shell + providers - -### Task 1.1: Zustand stores - -**Files:** -- Create: `ui/src/stores/ui.ts`, `ui/src/stores/project.ts`, `ui/src/stores/toast.ts` -- Test: `ui/src/stores/__tests__/ui.test.ts` - -- [ ] **Step 1: Create `ui/src/stores/ui.ts`** - ```ts - import { create } from "zustand"; - import { persist } from "zustand/middleware"; - - type Theme = "light" | "dark" | "system"; - - interface UIState { - sidebarCollapsed: boolean; - theme: Theme; - treeDrawerPinned: boolean; - linkDrawerPinned: boolean; - setSidebarCollapsed: (v: boolean) => void; - toggleSidebar: () => void; - setTheme: (t: Theme) => void; - setTreeDrawerPinned: (v: boolean) => void; - setLinkDrawerPinned: (v: boolean) => void; - } - - export const useUIStore = create()( - persist( - (set) => ({ - sidebarCollapsed: false, - theme: "system", - treeDrawerPinned: false, - linkDrawerPinned: false, - setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), - toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), - setTheme: (theme) => set({ theme }), - setTreeDrawerPinned: (treeDrawerPinned) => set({ treeDrawerPinned }), - setLinkDrawerPinned: (linkDrawerPinned) => set({ linkDrawerPinned }), - }), - { name: "docsiq-ui" }, - ), - ); - ``` - -- [ ] **Step 2: Create `ui/src/stores/project.ts`** - ```ts - import { create } from "zustand"; - - interface ProjectState { - slug: string; - setSlug: (s: string) => void; - } - - export const useProjectStore = create((set) => ({ - slug: "_default", - setSlug: (slug) => set({ slug }), - })); - ``` - -- [ ] **Step 3: Create `ui/src/stores/toast.ts`** - ```ts - import { create } from "zustand"; - - export type ToastKind = "info" | "success" | "error"; - - export interface Toast { - id: string; - kind: ToastKind; - message: string; - createdAt: number; - } - - interface ToastState { - toasts: Toast[]; - push: (kind: ToastKind, message: string) => void; - dismiss: (id: string) => void; - } - - export const useToastStore = create((set) => ({ - toasts: [], - push: (kind, message) => - set((s) => ({ - toasts: [ - ...s.toasts, - { id: crypto.randomUUID(), kind, message, createdAt: Date.now() }, - ], - })), - dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), - })); - ``` - -- [ ] **Step 4: Write `ui/src/stores/__tests__/ui.test.ts`** - ```ts - import { describe, it, expect, beforeEach } from "vitest"; - import { act } from "@testing-library/react"; - import { useUIStore } from "../ui"; - - describe("useUIStore", () => { - beforeEach(() => { - localStorage.clear(); - useUIStore.setState({ sidebarCollapsed: false, theme: "system", treeDrawerPinned: false, linkDrawerPinned: false }); - }); - - it("toggles sidebar", () => { - expect(useUIStore.getState().sidebarCollapsed).toBe(false); - act(() => useUIStore.getState().toggleSidebar()); - expect(useUIStore.getState().sidebarCollapsed).toBe(true); - act(() => useUIStore.getState().toggleSidebar()); - expect(useUIStore.getState().sidebarCollapsed).toBe(false); - }); - - it("sets theme", () => { - act(() => useUIStore.getState().setTheme("dark")); - expect(useUIStore.getState().theme).toBe("dark"); - }); - - it("persists theme to localStorage", () => { - act(() => useUIStore.getState().setTheme("light")); - const persisted = JSON.parse(localStorage.getItem("docsiq-ui")!); - expect(persisted.state.theme).toBe("light"); - }); - }); - ``` - -- [ ] **Step 5: Run tests** - ```bash - cd ui && npm test -- stores/ - ``` - Expected: 3 pass. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/src/stores/ - git commit -m "feat(ui): zustand stores (ui, project, toast)" - ``` - -### Task 1.2: Typed API client + query key registry - -**Files:** -- Create: `ui/src/lib/api-client.ts` -- Create: `ui/src/types/api.ts` -- Create: `ui/src/hooks/api/keys.ts` -- Test: `ui/src/lib/__tests__/api-client.test.ts` - -- [ ] **Step 1: Create `ui/src/types/api.ts`** (shared response shapes with backend — hand-mirrored from `internal/api/handlers.go` + `notes_handlers.go`) - ```ts - export interface Stats { - documents: number; - chunks: number; - entities: number; - relationships: number; - communities: number; - notes: number; - last_indexed: string | null; - } - - export interface Project { slug: string; name: string; } - - export interface Note { - key: string; - content: string; - author?: string; - tags: string[]; - created_at: string; - updated_at: string; - } - - export interface NoteHit { - key: string; - title: string; - snippet: string; - tags: string[]; - rank: number; - } - - export interface Document { - id: string; - path: string; - title: string; - doc_type: string; - version: number; - is_latest: boolean; - created_at: number; - updated_at: number; - } - - export interface SearchHit { - chunk_id: string; - doc_id: string; - doc_title: string; - content: string; - score: number; - } - - export interface ApiError { error: string; request_id?: string; } - ``` - -- [ ] **Step 2: Create `ui/src/lib/api-client.ts`** - ```ts - import type { ApiError } from "@/types/api"; - - let bearer: string | null = null; - - function readBearerFromMeta(): string | null { - if (typeof document === "undefined") return null; - const m = document.querySelector('meta[name="docsiq-api-key"]'); - const v = m?.getAttribute("content"); - return v && v.length > 0 ? v : null; - } - - export function initAuth() { - bearer = readBearerFromMeta(); - } - - export class ApiErrorResponse extends Error { - status: number; - requestId?: string; - constructor(status: number, body: ApiError) { - super(body.error); - this.status = status; - this.requestId = body.request_id; - } - } - - export async function apiFetch( - path: string, - init: RequestInit = {}, - ): Promise { - const headers = new Headers(init.headers); - if (bearer) headers.set("Authorization", `Bearer ${bearer}`); - if (init.body && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - const res = await fetch(path, { ...init, headers }); - if (!res.ok) { - let body: ApiError = { error: `HTTP ${res.status}` }; - try { body = await res.json(); } catch { /* non-json body */ } - throw new ApiErrorResponse(res.status, body); - } - if (res.status === 204) return undefined as T; - return res.json() as Promise; - } - ``` - -- [ ] **Step 3: Create `ui/src/hooks/api/keys.ts`** - ```ts - export const qk = { - stats: (project: string) => ["stats", project] as const, - projects: () => ["projects"] as const, - notes: (project: string) => ["notes", project] as const, - note: (project: string, key: string) => ["note", project, key] as const, - notesTree: (project: string) => ["notes-tree", project] as const, - notesGraph: (project: string) => ["notes-graph", project] as const, - notesSearch: (project: string, q: string) => ["notes-search", project, q] as const, - docs: (project: string) => ["docs", project] as const, - doc: (project: string, id: string) => ["doc", project, id] as const, - search: (project: string, q: string, mode: string) => ["search", project, q, mode] as const, - entities: (project: string) => ["entities", project] as const, - communities: (project: string) => ["communities", project] as const, - activity: (project: string) => ["activity", project] as const, - }; - ``` - -- [ ] **Step 4: Write `ui/src/lib/__tests__/api-client.test.ts`** - ```ts - import { describe, it, expect } from "vitest"; - import { http, HttpResponse } from "msw"; - import { server } from "@/test/msw"; - import { apiFetch, ApiErrorResponse } from "../api-client"; - - describe("apiFetch", () => { - it("returns parsed json on 200", async () => { - server.use(http.get("/api/ok", () => HttpResponse.json({ hello: "world" }))); - const body = await apiFetch<{ hello: string }>("/api/ok"); - expect(body.hello).toBe("world"); - }); - - it("throws ApiErrorResponse on 4xx with error + request_id", async () => { - server.use( - http.get("/api/bad", () => - HttpResponse.json({ error: "nope", request_id: "req-123" }, { status: 400 }), - ), - ); - try { - await apiFetch("/api/bad"); - throw new Error("should not reach"); - } catch (e) { - expect(e).toBeInstanceOf(ApiErrorResponse); - expect((e as ApiErrorResponse).status).toBe(400); - expect((e as ApiErrorResponse).requestId).toBe("req-123"); - expect((e as ApiErrorResponse).message).toBe("nope"); - } - }); - - it("handles 204 no-content", async () => { - server.use(http.delete("/api/x", () => new HttpResponse(null, { status: 204 }))); - const r = await apiFetch("/api/x", { method: "DELETE" }); - expect(r).toBeUndefined(); - }); - }); - ``` - -- [ ] **Step 5: Run tests** - ```bash - cd ui && npm test -- lib/ - ``` - Expected: 3 pass. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/src/types/ ui/src/lib/api-client.ts ui/src/hooks/api/keys.ts ui/src/lib/__tests__/ - git commit -m "feat(ui): typed api client + query key registry" - ``` - -### Task 1.3: i18n scaffold + formatting helpers - -**Files:** -- Create: `ui/src/i18n/en.ts`, `ui/src/i18n/index.ts` -- Create: `ui/src/lib/format.ts` -- Test: `ui/src/lib/__tests__/format.test.ts` - -- [ ] **Step 1: Create `ui/src/i18n/en.ts`** - ```ts - export const en = { - common: { - loading: "Loading…", - error: "Something went wrong.", - retry: "Retry", - cancel: "Cancel", - save: "Save", - delete: "Delete", - close: "Close", - }, - nav: { - home: "Home", - notes: "Notes", - documents: "Documents", - graph: "Graph", - mcp: "MCP console", - search: "Search or jump to…", - searchShort: "Search", - skipToMain: "Skip to main content", - }, - home: { - sinceLastVisit: "Since your last visit", - nothingNew: "Nothing new since your last visit.", - viewFullActivity: "View full activity", - pinnedNotes: "Pinned notes", - graphGlance: "Graph glance", - stats: { - notes: "Notes", - docs: "Docs", - entities: "Entities", - communities: "Communities", - updated: "Updated", - }, - }, - notes: { - writtenBy: "Written by", - linksIn: "in", - linksOut: "out", - noContent: "This note has no content.", - invalidKey: "Invalid key — use letters, digits, /, -, _", - }, - } as const; - - export type Messages = typeof en; - ``` - -- [ ] **Step 2: Create `ui/src/i18n/index.ts`** - ```ts - import { en } from "./en"; - - type PathsOf = T extends string - ? P - : T extends object - ? { - [K in keyof T & string]: PathsOf; - }[keyof T & string] - : never; - - export type MessageKey = PathsOf; - - export function t(key: MessageKey): string { - const parts = (key as string).split("."); - let cur: unknown = en; - for (const p of parts) { - if (typeof cur !== "object" || cur === null || !(p in cur)) return key as string; - cur = (cur as Record)[p]; - } - return typeof cur === "string" ? cur : (key as string); - } - ``` - -- [ ] **Step 3: Create `ui/src/lib/format.ts`** - ```ts - const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - - export function formatRelativeTime(fromMs: number, now: number = Date.now()): string { - const diffMs = fromMs - now; - const abs = Math.abs(diffMs); - const min = 60_000; - const hr = 60 * min; - const day = 24 * hr; - if (abs < min) return rtf.format(Math.round(diffMs / 1000), "second"); - if (abs < hr) return rtf.format(Math.round(diffMs / min), "minute"); - if (abs < day) return rtf.format(Math.round(diffMs / hr), "hour"); - return rtf.format(Math.round(diffMs / day), "day"); - } - - export function formatCount(n: number): string { - if (n < 1000) return String(n); - if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + "k"; - return (n / 1_000_000).toFixed(1) + "m"; - } - ``` - -- [ ] **Step 4: Write `ui/src/lib/__tests__/format.test.ts`** - ```ts - import { describe, it, expect } from "vitest"; - import { formatRelativeTime, formatCount } from "../format"; - - describe("formatRelativeTime", () => { - const now = new Date("2026-04-18T12:00:00Z").getTime(); - it("formats minutes-ago", () => { - expect(formatRelativeTime(now - 3 * 60_000, now)).toMatch(/3 minutes? ago/); - }); - it("formats hours-ago", () => { - expect(formatRelativeTime(now - 2 * 3600_000, now)).toMatch(/2 hours? ago/); - }); - it("formats days-ago", () => { - expect(formatRelativeTime(now - 3 * 86400_000, now)).toMatch(/3 days? ago/); - }); - }); - - describe("formatCount", () => { - it("raw for < 1k", () => expect(formatCount(42)).toBe("42")); - it("k for thousands", () => expect(formatCount(1234)).toBe("1.2k")); - it("k rounded for >= 10k", () => expect(formatCount(12_345)).toBe("12k")); - it("m for millions", () => expect(formatCount(1_234_567)).toBe("1.2m")); - }); - ``` - -- [ ] **Step 5: Run** - ```bash - cd ui && npm test -- lib/ - ``` - Expected: previous 3 + 7 new = 10 pass. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/src/i18n/ ui/src/lib/format.ts ui/src/lib/__tests__/format.test.ts - git commit -m "feat(ui): i18n scaffold + formatRelativeTime / formatCount" - ``` - -### Task 1.4: Providers root (QueryClient + Theme + BrowserRouter) - -**Files:** -- Create: `ui/src/components/layout/Providers.tsx` -- Modify: `ui/src/App.tsx` - -- [ ] **Step 1: Create `ui/src/components/layout/Providers.tsx`** - ```tsx - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { useEffect, useState, type ReactNode } from "react"; - import { BrowserRouter } from "react-router-dom"; - import { useUIStore } from "@/stores/ui"; - - export function Providers({ children }: { children: ReactNode }) { - const [client] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: (failureCount, error: unknown) => { - const status = (error as { status?: number })?.status ?? 0; - if (status >= 400 && status < 500) return false; - return failureCount < 3; - }, - refetchOnWindowFocus: false, - }, - }, - }), - ); - - const theme = useUIStore((s) => s.theme); - useEffect(() => { - const root = document.documentElement; - const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const effective = theme === "system" ? (systemDark ? "dark" : "light") : theme; - root.dataset.theme = effective; - }, [theme]); - - return ( - - {children} - - ); - } - ``` - -- [ ] **Step 2: Replace `ui/src/App.tsx`** - ```tsx - import { useEffect } from "react"; - import { Providers } from "@/components/layout/Providers"; - import { initAuth } from "@/lib/api-client"; - - export default function App() { - useEffect(() => { - initAuth(); - }, []); - - return ( - -
-
-

docsiq

-

- wave-1 scaffold -

-
-
-
- ); - } - ``` - -- [ ] **Step 3: Typecheck** - ```bash - cd ui && npm run typecheck - ``` - Expected: exit 0. - -- [ ] **Step 4: Commit** - ```bash - cd .. - git add ui/src/components/layout/Providers.tsx ui/src/App.tsx - git commit -m "feat(ui): Providers (QueryClient + Router + theme effect)" - ``` - -### Task 1.5: Layout shell — Sidebar + TopBar + Shell - -**Files:** -- Create: `ui/src/components/layout/SkipLink.tsx` -- Create: `ui/src/components/layout/Sidebar.tsx` -- Create: `ui/src/components/layout/TopBar.tsx` -- Create: `ui/src/components/layout/Shell.tsx` -- Test: `ui/src/components/layout/__tests__/Shell.test.tsx` - -- [ ] **Step 1: Create `ui/src/components/layout/SkipLink.tsx`** - ```tsx - export function SkipLink() { - return ( - - Skip to main content - - ); - } - ``` - -- [ ] **Step 2: Create `ui/src/components/layout/Sidebar.tsx`** - ```tsx - import { NavLink } from "react-router-dom"; - import { Home as HomeIcon, FileText, BookOpen, Network, Terminal } from "lucide-react"; - import { cn } from "@/lib/utils"; - import { useUIStore } from "@/stores/ui"; - import { t } from "@/i18n"; - - interface NavItem { to: string; label: string; icon: React.ComponentType<{ size?: number }>; chord: string; } - - const ITEMS: NavItem[] = [ - { to: "/", label: t("nav.home"), icon: HomeIcon, chord: "G H" }, - { to: "/notes", label: t("nav.notes"), icon: FileText, chord: "G N" }, - { to: "/docs", label: t("nav.documents"), icon: BookOpen, chord: "G D" }, - { to: "/graph", label: t("nav.graph"), icon: Network, chord: "G G" }, - { to: "/mcp", label: t("nav.mcp"), icon: Terminal, chord: "G M" }, - ]; - - export function Sidebar() { - const collapsed = useUIStore((s) => s.sidebarCollapsed); - - return ( - - ); - } - ``` - -- [ ] **Step 3: Create `ui/src/components/layout/TopBar.tsx`** - ```tsx - import { useUIStore } from "@/stores/ui"; - import { t } from "@/i18n"; - import { PanelLeft } from "lucide-react"; - - interface TopBarProps { onCommandOpen: () => void; } - - export function TopBar({ onCommandOpen }: TopBarProps) { - const toggle = useUIStore((s) => s.toggleSidebar); - - return ( -
- - docsiq - / - _default - -
- ); - } - ``` - -- [ ] **Step 4: Create `ui/src/components/layout/Shell.tsx`** - ```tsx - import { type ReactNode, useState } from "react"; - import { Sidebar } from "./Sidebar"; - import { TopBar } from "./TopBar"; - import { SkipLink } from "./SkipLink"; - - export function Shell({ children }: { children: ReactNode }) { - const [cmdOpen, setCmdOpen] = useState(false); - return ( -
- - setCmdOpen(true)} /> -
- -
- {children} -
-
- {/* CommandPalette drop-in happens in Wave 3; until then cmdOpen is unused */} - {cmdOpen ? "open" : "closed"} -
- ); - } - ``` - -- [ ] **Step 5: Write `ui/src/components/layout/__tests__/Shell.test.tsx`** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { MemoryRouter } from "react-router-dom"; - import { Shell } from "../Shell"; - - describe("Shell", () => { - it("renders sidebar, topbar, skip link, and main landmark", () => { - render( - - -
content
-
-
, - ); - expect(screen.getByRole("navigation", { name: /primary/i })).toBeInTheDocument(); - expect(screen.getByRole("main")).toBeInTheDocument(); - expect(screen.getByText(/skip to main content/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /open command palette/i })).toBeInTheDocument(); - expect(screen.getByText("content")).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 6: Run** - ```bash - cd ui && npm test -- layout/ - ``` - Expected: 1 pass. - -- [ ] **Step 7: Commit** - ```bash - cd .. - git add ui/src/components/layout/ - git commit -m "feat(ui): layout shell (Sidebar, TopBar, SkipLink)" - ``` - -### Task 1.6: useHotkey hook + sidebar toggle wired up - -**Files:** -- Create: `ui/src/hooks/useHotkey.ts` -- Test: `ui/src/hooks/__tests__/useHotkey.test.tsx` -- Modify: `ui/src/components/layout/Shell.tsx` - -- [ ] **Step 1: Create `ui/src/hooks/useHotkey.ts`** - ```ts - import { useEffect, useRef } from "react"; - - interface Options { - enabled?: boolean; - preventDefault?: boolean; - } - - // combo: "mod+k" or "mod+\\" or "g,h" (chord — g then h within 1s) - export function useHotkey(combo: string, handler: (e: KeyboardEvent) => void, opts: Options = {}) { - const { enabled = true, preventDefault = true } = opts; - const handlerRef = useRef(handler); - handlerRef.current = handler; - - useEffect(() => { - if (!enabled) return; - const chord = combo.includes(","); - let lastKey: string | null = null; - let lastTime = 0; - - const onKeyDown = (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - const mod = e.metaKey || e.ctrlKey; - - if (chord) { - const [first, second] = combo.split(","); - if (!lastKey) { - if (key === first) { lastKey = key; lastTime = Date.now(); return; } - } else { - if (key === second && Date.now() - lastTime < 1000) { - if (preventDefault) e.preventDefault(); - handlerRef.current(e); - } - lastKey = null; - } - return; - } - - const parts = combo.split("+"); - const needsMod = parts.includes("mod"); - const target = parts[parts.length - 1]; - if (needsMod === mod && key === target) { - if (preventDefault) e.preventDefault(); - handlerRef.current(e); - } - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [combo, enabled, preventDefault]); - } - ``` - -- [ ] **Step 2: Write `ui/src/hooks/__tests__/useHotkey.test.tsx`** - ```tsx - import { describe, it, expect, vi } from "vitest"; - import { renderHook } from "@testing-library/react"; - import { useHotkey } from "../useHotkey"; - - function fireKey(key: string, mod = false) { - const e = new KeyboardEvent("keydown", { key, metaKey: mod, ctrlKey: mod, cancelable: true }); - window.dispatchEvent(e); - } - - describe("useHotkey", () => { - it("fires on mod+k", () => { - const fn = vi.fn(); - renderHook(() => useHotkey("mod+k", fn)); - fireKey("k", true); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it("does not fire on k without mod", () => { - const fn = vi.fn(); - renderHook(() => useHotkey("mod+k", fn)); - fireKey("k", false); - expect(fn).not.toHaveBeenCalled(); - }); - - it("fires on G H chord", () => { - const fn = vi.fn(); - renderHook(() => useHotkey("g,h", fn)); - fireKey("g"); - fireKey("h"); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it("aborts chord if wrong second key", () => { - const fn = vi.fn(); - renderHook(() => useHotkey("g,h", fn)); - fireKey("g"); - fireKey("x"); - expect(fn).not.toHaveBeenCalled(); - }); - }); - ``` - -- [ ] **Step 3: Run** - ```bash - cd ui && npm test -- hooks/ - ``` - Expected: 4 pass. - -- [ ] **Step 4: Wire `⌘\` sidebar toggle + G-chord nav in Shell.tsx** — replace `ui/src/components/layout/Shell.tsx` contents: - ```tsx - import { type ReactNode, useState } from "react"; - import { useNavigate } from "react-router-dom"; - import { Sidebar } from "./Sidebar"; - import { TopBar } from "./TopBar"; - import { SkipLink } from "./SkipLink"; - import { useUIStore } from "@/stores/ui"; - import { useHotkey } from "@/hooks/useHotkey"; - - export function Shell({ children }: { children: ReactNode }) { - const [cmdOpen, setCmdOpen] = useState(false); - const toggleSidebar = useUIStore((s) => s.toggleSidebar); - const navigate = useNavigate(); - - useHotkey("mod+\\", () => toggleSidebar()); - useHotkey("mod+k", () => setCmdOpen((v) => !v)); - useHotkey("g,h", () => navigate("/")); - useHotkey("g,n", () => navigate("/notes")); - useHotkey("g,d", () => navigate("/docs")); - useHotkey("g,g", () => navigate("/graph")); - useHotkey("g,m", () => navigate("/mcp")); - - return ( -
- - setCmdOpen(true)} /> -
- -
- {children} -
-
- {cmdOpen ? "open" : "closed"} -
- ); - } - ``` - -- [ ] **Step 5: Typecheck + test** - ```bash - cd ui && npm run typecheck && npm test -- layout/ - ``` - Expected: typecheck exit 0; Shell test still passes. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/src/hooks/ ui/src/components/layout/Shell.tsx - git commit -m "feat(ui): useHotkey + wire ⌘\\, ⌘K, G-chord navigation" - ``` - ---- - -## Wave 2 — Router + route stubs + reduced-motion - -### Task 2.1: Route stubs for all 5 destinations - -**Files:** -- Create: `ui/src/routes/Home.tsx`, `ui/src/routes/Graph.tsx`, `ui/src/routes/MCPConsole.tsx` -- Create: `ui/src/routes/notes/NotesLayout.tsx`, `ui/src/routes/notes/NoteView.tsx` -- Create: `ui/src/routes/documents/DocumentsList.tsx`, `ui/src/routes/documents/DocumentView.tsx` -- Modify: `ui/src/App.tsx` (wire router) - -- [ ] **Step 1: Create 5 stub route files** with identical pattern (replace `` per file): - ```tsx - export default function RouteNameStub() { - return ( -
-

Route name

-

- Stub — implemented in later wave. -

-
- ); - } - ``` - Create the 7 files listed under "Files" above, each returning a stub identifying the route by name. - -- [ ] **Step 2: Rewrite `ui/src/App.tsx`** - ```tsx - import { useEffect } from "react"; - import { Route, Routes } from "react-router-dom"; - import { Providers } from "@/components/layout/Providers"; - import { Shell } from "@/components/layout/Shell"; - import { initAuth } from "@/lib/api-client"; - - import Home from "@/routes/Home"; - import NotesLayout from "@/routes/notes/NotesLayout"; - import NoteView from "@/routes/notes/NoteView"; - import DocumentsList from "@/routes/documents/DocumentsList"; - import DocumentView from "@/routes/documents/DocumentView"; - import Graph from "@/routes/Graph"; - import MCPConsole from "@/routes/MCPConsole"; - - export default function App() { - useEffect(() => { initAuth(); }, []); - return ( - - - - } /> - }> - } /> - - } /> - } /> - } /> - } /> - } /> - - - - ); - } - - function NotFound() { - return ( -
-

Not found

-

No such page.

-
- ); - } - ``` - -- [ ] **Step 3: Build + run** - ```bash - cd ui && npm run build && ls dist/ - ``` - Expected: exit 0, `dist/index.html` + `dist/assets/*.js` + `dist/assets/*.css` present. - -- [ ] **Step 4: Commit (including fresh ui/dist)** - ```bash - cd .. - git add ui/src/routes/ ui/src/App.tsx ui/dist/ - git commit -m "feat(ui): router + 5 route stubs" - ``` - -### Task 2.2: `useReducedMotion` hook + motion utilities - -**Files:** -- Create: `ui/src/hooks/useReducedMotion.ts` -- Create: `ui/src/lib/motion.ts` -- Test: `ui/src/hooks/__tests__/useReducedMotion.test.tsx` - -- [ ] **Step 1: Create `ui/src/hooks/useReducedMotion.ts`** - ```ts - import { useEffect, useState } from "react"; - - export function useReducedMotion(): boolean { - const [prefers, setPrefers] = useState(() => - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches, - ); - - useEffect(() => { - if (typeof window === "undefined") return; - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - const onChange = () => setPrefers(mq.matches); - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); - }, []); - - return prefers; - } - ``` - -- [ ] **Step 2: Create `ui/src/lib/motion.ts`** - ```ts - import type { Transition } from "framer-motion"; - - export const enterTransition: Transition = { - duration: 0.18, - ease: [0.3, 0, 0, 1], - }; - - export const exitTransition: Transition = { - duration: 0.12, - ease: [0.7, 0, 1, 0.3], - }; - - export function reducedMotionTransition(): Transition { - return { duration: 0 }; - } - ``` - -- [ ] **Step 3: Write `ui/src/hooks/__tests__/useReducedMotion.test.tsx`** - ```tsx - import { describe, it, expect, vi } from "vitest"; - import { renderHook } from "@testing-library/react"; - import { useReducedMotion } from "../useReducedMotion"; - - function mockMatchMedia(matches: boolean) { - Object.defineProperty(window, "matchMedia", { - writable: true, - configurable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); - } - - describe("useReducedMotion", () => { - it("returns true when reduce is set", () => { - mockMatchMedia(true); - const { result } = renderHook(() => useReducedMotion()); - expect(result.current).toBe(true); - }); - it("returns false otherwise", () => { - mockMatchMedia(false); - const { result } = renderHook(() => useReducedMotion()); - expect(result.current).toBe(false); - }); - }); - ``` - -- [ ] **Step 4: Run** - ```bash - cd ui && npm test -- hooks/ - ``` - Expected: all hook tests pass. - -- [ ] **Step 5: Commit** - ```bash - cd .. - git add ui/src/hooks/useReducedMotion.ts ui/src/lib/motion.ts ui/src/hooks/__tests__/useReducedMotion.test.tsx - git commit -m "feat(ui): useReducedMotion + motion tokens" - ``` - ---- - -## Wave 3 — Command palette (⌘K) - -### Task 3.1: CommandPalette component - -**Files:** -- Create: `ui/src/components/command/CommandPalette.tsx` -- Create: `ui/src/hooks/api/useCommand.ts` -- Modify: `ui/src/components/layout/Shell.tsx` (render palette) -- Test: `ui/src/components/command/__tests__/CommandPalette.test.tsx` - -- [ ] **Step 1: Create `ui/src/hooks/api/useCommand.ts`** - ```ts - import { useQuery } from "@tanstack/react-query"; - import { apiFetch } from "@/lib/api-client"; - import { qk } from "./keys"; - import type { NoteHit, SearchHit } from "@/types/api"; - - export function useCommandSearch(project: string, query: string) { - return useQuery({ - queryKey: ["command-search", project, query], - enabled: query.trim().length > 0, - queryFn: async () => { - const [notes, docs] = await Promise.all([ - apiFetch<{ hits: NoteHit[] }>( - `/api/projects/${encodeURIComponent(project)}/search?q=${encodeURIComponent(query)}`, - ).catch(() => ({ hits: [] as NoteHit[] })), - apiFetch<{ hits: SearchHit[] }>( - `/api/search?project=${encodeURIComponent(project)}&q=${encodeURIComponent(query)}&mode=local&top_k=5`, - ).catch(() => ({ hits: [] as SearchHit[] })), - ]); - return { notes: notes.hits, docs: docs.hits }; - }, - staleTime: 10_000, - }); - /* qk import kept for future merged key usage */ - void qk; - } - ``` - -- [ ] **Step 2: Create `ui/src/components/command/CommandPalette.tsx`** - ```tsx - import { useState } from "react"; - import { useNavigate } from "react-router-dom"; - import { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - } from "@/components/ui/command"; - import { useProjectStore } from "@/stores/project"; - import { useCommandSearch } from "@/hooks/api/useCommand"; - - interface Props { open: boolean; onOpenChange: (v: boolean) => void; } - - export function CommandPalette({ open, onOpenChange }: Props) { - const [q, setQ] = useState(""); - const navigate = useNavigate(); - const project = useProjectStore((s) => s.slug); - const { data } = useCommandSearch(project, q); - - const close = () => { onOpenChange(false); setQ(""); }; - - return ( - - - - - {q ? "No results." : "Type to search."} - - - { navigate("/"); close(); }}>Home - { navigate("/notes"); close(); }}>Notes - { navigate("/docs"); close(); }}>Documents - { navigate("/graph"); close(); }}>Graph - { navigate("/mcp"); close(); }}>MCP console - - - {data && data.notes.length > 0 && ( - - {data.notes.slice(0, 5).map((n) => ( - { navigate(`/notes/${n.key}`); close(); }} - > - NOTE - {n.title || n.key} - - ))} - - )} - - {data && data.docs.length > 0 && ( - - {data.docs.slice(0, 5).map((d) => ( - { navigate(`/docs/${d.doc_id}`); close(); }} - > - DOC - {d.doc_title} - - ))} - - )} - - - - ); - } - ``` - -- [ ] **Step 3: Update `ui/src/components/layout/Shell.tsx`** — replace the `` placeholder with ``. Add import at the top: `import { CommandPalette } from "@/components/command/CommandPalette";`. - -- [ ] **Step 4: Write `ui/src/components/command/__tests__/CommandPalette.test.tsx`** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import userEvent from "@testing-library/user-event"; - import { MemoryRouter } from "react-router-dom"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { CommandPalette } from "../CommandPalette"; - - function wrap(ui: React.ReactNode) { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return ( - - {ui} - - ); - } - - describe("CommandPalette", () => { - it("renders input + Pages group when open", () => { - render(wrap( {}} />)); - expect(screen.getByPlaceholderText(/search notes/i)).toBeInTheDocument(); - expect(screen.getByText(/home/i)).toBeInTheDocument(); - expect(screen.getByText(/notes/i)).toBeInTheDocument(); - }); - - it("shows 'Type to search.' when empty", () => { - render(wrap( {}} />)); - expect(screen.getByText(/type to search/i)).toBeInTheDocument(); - }); - - it("filters input via typing", async () => { - const user = userEvent.setup(); - render(wrap( {}} />)); - const input = screen.getByPlaceholderText(/search notes/i); - await user.type(input, "hello"); - expect(input).toHaveValue("hello"); - }); - }); - ``` - -- [ ] **Step 5: Run** - ```bash - cd ui && npm test -- command/ - ``` - Expected: 3 pass. - -- [ ] **Step 6: Commit** - ```bash - cd .. - git add ui/src/components/command/ ui/src/hooks/api/useCommand.ts ui/src/components/layout/Shell.tsx - git commit -m "feat(ui): ⌘K command palette (pages + notes + docs search)" - ``` - ---- - -## Wave 4 — Home screen - -### Task 4.1: API hooks for stats + activity + recent notes + graph glance - -**Files:** -- Create: `ui/src/hooks/api/useStats.ts`, `ui/src/hooks/api/useActivity.ts`, `ui/src/hooks/api/useNotes.ts`, `ui/src/hooks/api/useGraph.ts` -- Test: `ui/src/hooks/api/__tests__/useStats.test.tsx` - -- [ ] **Step 1: Create `ui/src/hooks/api/useStats.ts`** - ```ts - import { useQuery } from "@tanstack/react-query"; - import { apiFetch } from "@/lib/api-client"; - import { qk } from "./keys"; - import type { Stats } from "@/types/api"; - - export function useStats(project: string) { - return useQuery({ - queryKey: qk.stats(project), - queryFn: () => apiFetch(`/api/stats?project=${encodeURIComponent(project)}`), - }); - } - ``` - -- [ ] **Step 2: Create `ui/src/hooks/api/useActivity.ts`** — derived client-side from existing list endpoints (since there's no single `/api/activity` endpoint yet; compose one in code). - ```ts - import { useQuery } from "@tanstack/react-query"; - import { apiFetch } from "@/lib/api-client"; - import { qk } from "./keys"; - import type { Note, Document } from "@/types/api"; - - export type ActivityEventKind = "note_added" | "note_updated" | "doc_indexed" | "doc_error"; - - export interface ActivityEvent { - id: string; - kind: ActivityEventKind; - title: string; - detail?: string; - timestamp: number; // ms since epoch - href: string; - } - - export function useActivity(project: string) { - return useQuery({ - queryKey: qk.activity(project), - queryFn: async () => { - const [notes, docs] = await Promise.all([ - apiFetch(`/api/projects/${encodeURIComponent(project)}/notes`).catch(() => []), - apiFetch(`/api/documents?project=${encodeURIComponent(project)}`).catch(() => []), - ]); - const events: ActivityEvent[] = []; - for (const n of notes) { - const ts = new Date(n.updated_at).getTime(); - const isNew = ts === new Date(n.created_at).getTime(); - events.push({ - id: `note-${n.key}-${ts}`, - kind: isNew ? "note_added" : "note_updated", - title: n.key, - timestamp: ts, - href: `/notes/${n.key}`, - }); - } - for (const d of docs) { - events.push({ - id: `doc-${d.id}-${d.updated_at}`, - kind: "doc_indexed", - title: d.title || d.path, - detail: d.doc_type, - timestamp: d.updated_at * 1000, - href: `/docs/${d.id}`, - }); - } - events.sort((a, b) => b.timestamp - a.timestamp); - return events.slice(0, 20); - }, - refetchInterval: 10_000, - }); - } - ``` - -- [ ] **Step 3: Create `ui/src/hooks/api/useNotes.ts`** - ```ts - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - import { apiFetch } from "@/lib/api-client"; - import { qk } from "./keys"; - import type { Note } from "@/types/api"; - - export function useNotes(project: string) { - return useQuery({ - queryKey: qk.notes(project), - queryFn: () => apiFetch(`/api/projects/${encodeURIComponent(project)}/notes`), - }); - } - - export function useNote(project: string, key: string | undefined) { - return useQuery({ - queryKey: qk.note(project, key ?? ""), - enabled: !!key, - queryFn: () => - apiFetch(`/api/projects/${encodeURIComponent(project)}/notes/${encodeURIComponent(key!)}`), - }); - } - - export function useWriteNote(project: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (input: { key: string; content: string; author?: string; tags?: string[] }) => - apiFetch( - `/api/projects/${encodeURIComponent(project)}/notes/${encodeURIComponent(input.key)}`, - { method: "PUT", body: JSON.stringify(input) }, - ), - onSuccess: (_, v) => { - qc.invalidateQueries({ queryKey: qk.notes(project) }); - qc.invalidateQueries({ queryKey: qk.note(project, v.key) }); - qc.invalidateQueries({ queryKey: qk.notesGraph(project) }); - qc.invalidateQueries({ queryKey: qk.activity(project) }); - }, - }); - } - - export function useDeleteNote(project: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (key: string) => - apiFetch( - `/api/projects/${encodeURIComponent(project)}/notes/${encodeURIComponent(key)}`, - { method: "DELETE" }, - ), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.notes(project) }); - qc.invalidateQueries({ queryKey: qk.notesGraph(project) }); - qc.invalidateQueries({ queryKey: qk.activity(project) }); - }, - }); - } - ``` - -- [ ] **Step 4: Create `ui/src/hooks/api/useGraph.ts`** - ```ts - import { useQuery } from "@tanstack/react-query"; - import { apiFetch } from "@/lib/api-client"; - import { qk } from "./keys"; - - export interface GraphNode { id: string; label: string; kind: "entity" | "note" | "community"; } - export interface GraphEdge { source: string; target: string; } - export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; } - - export function useNotesGraph(project: string) { - return useQuery({ - queryKey: qk.notesGraph(project), - queryFn: () => - apiFetch(`/api/projects/${encodeURIComponent(project)}/graph`), - }); - } - ``` - -- [ ] **Step 5: Write `ui/src/hooks/api/__tests__/useStats.test.tsx`** - ```tsx - import { describe, it, expect } from "vitest"; - import { renderHook, waitFor } from "@testing-library/react"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { useStats } from "../useStats"; - - function wrap() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - // eslint-disable-next-line react/display-name - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); - } - - describe("useStats", () => { - it("fetches and returns stats from MSW handler", async () => { - const { result } = renderHook(() => useStats("_default"), { wrapper: wrap() }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.documents).toBe(42); - expect(result.current.data?.notes).toBe(17); - }); - }); - ``` - -- [ ] **Step 6: Run** - ```bash - cd ui && npm test -- hooks/api/ - ``` - Expected: 1 pass. - -- [ ] **Step 7: Commit** - ```bash - cd .. - git add ui/src/hooks/api/ - git commit -m "feat(ui): api hooks (stats, activity, notes, graph)" - ``` - -### Task 4.2: StatsStrip component - -**Files:** -- Create: `ui/src/components/layout/StatsStrip.tsx` -- Test: `ui/src/components/layout/__tests__/StatsStrip.test.tsx` - -- [ ] **Step 1: Create component** - ```tsx - import { formatCount, formatRelativeTime } from "@/lib/format"; - import { t } from "@/i18n"; - import type { Stats } from "@/types/api"; - - interface Props { stats: Stats | undefined; delta?: { notes?: number }; } - - const CARD = "flex-1 border border-[var(--color-border)] rounded-md p-3 font-mono"; - const LABEL = "text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]"; - const VALUE = "text-xl text-[var(--color-text)] mt-1"; - const DELTA = "text-xs text-[var(--color-accent)]"; - - export function StatsStrip({ stats, delta }: Props) { - const tiles = [ - { label: t("home.stats.notes"), value: stats ? formatCount(stats.notes) : "—", delta: delta?.notes }, - { label: t("home.stats.docs"), value: stats ? formatCount(stats.documents) : "—" }, - { label: t("home.stats.entities"), value: stats ? formatCount(stats.entities) : "—" }, - { label: t("home.stats.communities"), value: stats ? formatCount(stats.communities) : "—" }, - { label: t("home.stats.updated"), value: stats?.last_indexed ? formatRelativeTime(new Date(stats.last_indexed).getTime()) : "—" }, - ]; - return ( -
- {tiles.map((tl) => ( -
-
{tl.label}
-
- {tl.value} - {tl.delta !== undefined && tl.delta > 0 && ( - +{tl.delta} - )} -
-
- ))} -
- ); - } - ``` - -- [ ] **Step 2: Write test** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { StatsStrip } from "../StatsStrip"; - - describe("StatsStrip", () => { - it("renders placeholders on undefined stats", () => { - render(); - expect(screen.getAllByText("—").length).toBeGreaterThan(0); - }); - it("renders counts + delta", () => { - render( - , - ); - expect(screen.getByText("17")).toBeInTheDocument(); - expect(screen.getByText("+2")).toBeInTheDocument(); - expect(screen.getByText("42")).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 3: Run + commit** - ```bash - cd ui && npm test -- StatsStrip - cd .. - git add ui/src/components/layout/StatsStrip.tsx ui/src/components/layout/__tests__/StatsStrip.test.tsx - git commit -m "feat(ui): StatsStrip with formatted counts + deltas" - ``` - -### Task 4.3: ActivityFeed component - -**Files:** -- Create: `ui/src/components/activity/EventBadge.tsx`, `ui/src/components/activity/EventRow.tsx`, `ui/src/components/activity/ActivityFeed.tsx` -- Create: `ui/src/hooks/useLastVisit.ts` -- Test: `ui/src/components/activity/__tests__/ActivityFeed.test.tsx` - -- [ ] **Step 1: Create `ui/src/hooks/useLastVisit.ts`** - ```ts - import { useEffect, useState } from "react"; - - const KEY = "docsiq-last-visit"; - - export function useLastVisit() { - const [last, setLast] = useState(() => { - const v = localStorage.getItem(KEY); - return v ? Number(v) : 0; - }); - - function touch() { - const now = Date.now(); - localStorage.setItem(KEY, String(now)); - setLast(now); - } - - return { lastVisit: last, touch }; - } - - export function useTouchOnUnmount() { - useEffect(() => () => { localStorage.setItem("docsiq-last-visit", String(Date.now())); }, []); - } - ``` - -- [ ] **Step 2: Create `ui/src/components/activity/EventBadge.tsx`** - ```tsx - import type { ActivityEventKind } from "@/hooks/api/useActivity"; - - const STYLES: Record = { - note_added: { label: "+ NOTE", color: "var(--color-semantic-new)" }, - note_updated: { label: "~ NOTE", color: "var(--color-semantic-new)" }, - doc_indexed: { label: "INDEX", color: "var(--color-semantic-index)" }, - doc_error: { label: "ERROR", color: "var(--color-semantic-error)" }, - }; - - export function EventBadge({ kind }: { kind: ActivityEventKind }) { - const { label, color } = STYLES[kind]; - return ( - - {label} - - ); - } - ``` - -- [ ] **Step 3: Create `ui/src/components/activity/EventRow.tsx`** - ```tsx - import { Link } from "react-router-dom"; - import { EventBadge } from "./EventBadge"; - import { formatRelativeTime } from "@/lib/format"; - import type { ActivityEvent } from "@/hooks/api/useActivity"; - - export function EventRow({ event, isNew }: { event: ActivityEvent; isNew: boolean }) { - return ( - - - - {event.title} - {event.detail && · {event.detail}} - - - {formatRelativeTime(event.timestamp)} - - - ); - } - ``` - -- [ ] **Step 4: Create `ui/src/components/activity/ActivityFeed.tsx`** - ```tsx - import { EventRow } from "./EventRow"; - import { t } from "@/i18n"; - import type { ActivityEvent } from "@/hooks/api/useActivity"; - - interface Props { events: ActivityEvent[]; lastVisit: number; } - - export function ActivityFeed({ events, lastVisit }: Props) { - if (events.length === 0) { - return ( -
- {t("home.nothingNew")} -
- ); - } - return ( -
-

- {t("home.sinceLastVisit")} -

-
- {events.map((e) => ( - lastVisit} /> - ))} -
-
- ); - } - ``` - -- [ ] **Step 5: Write test** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { MemoryRouter } from "react-router-dom"; - import { ActivityFeed } from "../ActivityFeed"; - - describe("ActivityFeed", () => { - it("shows empty state on no events", () => { - render(); - expect(screen.getByText(/nothing new/i)).toBeInTheDocument(); - }); - it("renders events and highlights ones newer than lastVisit", () => { - const now = Date.now(); - render( - - - , - ); - expect(screen.getByText("+ NOTE")).toBeInTheDocument(); - expect(screen.getByText("INDEX")).toBeInTheDocument(); - expect(screen.getByText("jwt")).toBeInTheDocument(); - expect(screen.getByText("api.md")).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 6: Run + commit** - ```bash - cd ui && npm test -- activity/ - cd .. - git add ui/src/components/activity/ ui/src/hooks/useLastVisit.ts - git commit -m "feat(ui): ActivityFeed + EventRow + EventBadge + useLastVisit" - ``` - -### Task 4.4: GraphGlance component - -**Files:** -- Create: `ui/src/components/graph/GlanceView.tsx` -- Test: `ui/src/components/graph/__tests__/GlanceView.test.tsx` - -- [ ] **Step 1: Create component (pure SVG, no d3 yet)** - ```tsx - import type { GraphData } from "@/hooks/api/useGraph"; - import { useMemo } from "react"; - - interface Props { data: GraphData | undefined; maxNodes?: number; } - - const COLOR: Record = { - entity: "var(--color-semantic-new)", - note: "var(--color-semantic-graph)", - community: "var(--color-semantic-index)", - }; - - export function GlanceView({ data, maxNodes = 30 }: Props) { - const layout = useMemo(() => { - if (!data) return null; - const nodes = data.nodes.slice(0, maxNodes); - const n = nodes.length; - const radius = 60; - const placed = nodes.map((node, i) => ({ - node, - x: 110 + radius * Math.cos((2 * Math.PI * i) / n), - y: 70 + radius * Math.sin((2 * Math.PI * i) / n), - })); - const idx: Record = {}; - placed.forEach((p) => (idx[p.node.id] = { x: p.x, y: p.y })); - const edges = data.edges.filter((e) => idx[e.source] && idx[e.target]).slice(0, 60); - return { placed, edges, idx }; - }, [data, maxNodes]); - - if (!data || !layout) { - return ( -
- loading… -
- ); - } - - return ( - - {layout.edges.map((e, i) => ( - - ))} - {layout.placed.map((p) => ( - - ))} - - ); - } - ``` - -- [ ] **Step 2: Write test** - ```tsx - import { describe, it, expect } from "vitest"; - import { render } from "@testing-library/react"; - import { GlanceView } from "../GlanceView"; - - describe("GlanceView", () => { - it("shows loading state on undefined", () => { - const { getByText } = render(); - expect(getByText(/loading/i)).toBeInTheDocument(); - }); - it("renders N circles for N nodes (capped by maxNodes)", () => { - const { container } = render( - ({ - id: String(i), label: "n", kind: "entity", - })), - edges: [{ source: "0", target: "1" }], - }} - />, - ); - expect(container.querySelectorAll("circle").length).toBe(5); - expect(container.querySelectorAll("line").length).toBe(1); - }); - }); - ``` - -- [ ] **Step 3: Run + commit** - ```bash - cd ui && npm test -- graph/ - cd .. - git add ui/src/components/graph/ - git commit -m "feat(ui): GlanceView SVG graph preview" - ``` - -### Task 4.5: Wire Home route with all pieces - -**Files:** -- Modify: `ui/src/routes/Home.tsx` -- Test: `ui/src/routes/__tests__/Home.test.tsx` - -- [ ] **Step 1: Rewrite `ui/src/routes/Home.tsx`** - ```tsx - import { useEffect, useMemo } from "react"; - import { StatsStrip } from "@/components/layout/StatsStrip"; - import { ActivityFeed } from "@/components/activity/ActivityFeed"; - import { GlanceView } from "@/components/graph/GlanceView"; - import { useProjectStore } from "@/stores/project"; - import { useStats } from "@/hooks/api/useStats"; - import { useActivity } from "@/hooks/api/useActivity"; - import { useNotes } from "@/hooks/api/useNotes"; - import { useNotesGraph } from "@/hooks/api/useGraph"; - import { useLastVisit } from "@/hooks/useLastVisit"; - import { t } from "@/i18n"; - - export default function Home() { - const project = useProjectStore((s) => s.slug); - const stats = useStats(project); - const activity = useActivity(project); - const notes = useNotes(project); - const graph = useNotesGraph(project); - const { lastVisit, touch } = useLastVisit(); - - const newCount = useMemo(() => { - if (!activity.data) return 0; - return activity.data.filter((e) => e.kind === "note_added" && e.timestamp > lastVisit).length; - }, [activity.data, lastVisit]); - - // Touch on unmount so we track "last time the user looked at Home" - useEffect(() => () => { touch(); }, [touch]); - - const recentNotes = (notes.data ?? []).slice(0, 5); - - return ( -
- -
- - -
-
- ); - } - ``` - -- [ ] **Step 2: Write integration test** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen, waitFor } from "@testing-library/react"; - import { MemoryRouter } from "react-router-dom"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { http, HttpResponse } from "msw"; - import { server } from "@/test/msw"; - import Home from "@/routes/Home"; - - function wrap() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return (node: React.ReactNode) => ( - - {node} - - ); - } - - describe("Home route", () => { - it("renders stats + empty activity + empty glance state", async () => { - server.use( - http.get("/api/projects/_default/notes", () => HttpResponse.json([])), - http.get("/api/documents", () => HttpResponse.json([])), - http.get("/api/projects/_default/graph", () => HttpResponse.json({ nodes: [], edges: [] })), - ); - render(wrap()()); - await waitFor(() => expect(screen.getByText(/since your last visit|nothing new/i)).toBeInTheDocument()); - expect(screen.getByRole("region", { name: /project statistics/i })).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 3: Run + build + commit** - ```bash - cd ui && npm test -- routes/ && npm run build - cd .. - git add ui/src/routes/Home.tsx ui/src/routes/__tests__/ ui/dist/ - git commit -m "feat(ui): Home route with stats + activity + glance + pinned" - ``` - ---- - -## Wave 5 — Notes workspace - -### Task 5.1: Markdown renderer - -**Files:** -- Create: `ui/src/lib/markdown.ts` -- Create: `ui/src/components/notes/MarkdownView.tsx`, `ui/src/components/notes/WikiLink.tsx` -- Test: `ui/src/lib/__tests__/markdown.test.ts`, `ui/src/components/notes/__tests__/MarkdownView.test.tsx` - -- [ ] **Step 1: Create `ui/src/lib/markdown.ts`** - ```ts - import MarkdownIt from "markdown-it"; - - export interface MarkdownPart { - kind: "html" | "wikilink"; - content: string; // html string OR wikilink target - label?: string; // for wikilinks with alias - } - - const WIKILINK = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g; - - // Configure markdown-it with safe defaults - export function createMd() { - const md = new MarkdownIt({ - html: false, - linkify: true, - breaks: false, - }); - // Open links in new tab with noopener - const defaultRender = md.renderer.rules.link_open || - ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); - md.renderer.rules.link_open = (tokens, idx, options, env, self) => { - const href = tokens[idx].attrGet("href") ?? ""; - if (/^https?:\/\//.test(href)) { - tokens[idx].attrSet("target", "_blank"); - tokens[idx].attrSet("rel", "noopener noreferrer"); - } - return defaultRender(tokens, idx, options, env, self); - }; - // Image: loading lazy - const defaultImg = md.renderer.rules.image || - ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); - md.renderer.rules.image = (tokens, idx, options, env, self) => { - tokens[idx].attrSet("loading", "lazy"); - return defaultImg(tokens, idx, options, env, self); - }; - return md; - } - - const md = createMd(); - - export function renderMarkdown(source: string): MarkdownPart[] { - // Strip YAML frontmatter - let body = source; - if (body.startsWith("---\n")) { - const end = body.indexOf("\n---", 4); - if (end > 0) body = body.slice(end + 4).replace(/^\n/, ""); - } - - const parts: MarkdownPart[] = []; - let lastIndex = 0; - for (const m of body.matchAll(WIKILINK)) { - const idx = m.index ?? 0; - if (idx > lastIndex) { - parts.push({ kind: "html", content: md.render(body.slice(lastIndex, idx)) }); - } - parts.push({ kind: "wikilink", content: m[1].trim(), label: m[2]?.trim() }); - lastIndex = idx + m[0].length; - } - if (lastIndex < body.length) { - parts.push({ kind: "html", content: md.render(body.slice(lastIndex)) }); - } - return parts; - } - ``` - -- [ ] **Step 2: Create `ui/src/components/notes/WikiLink.tsx`** - ```tsx - import { Link } from "react-router-dom"; - - interface Props { target: string; label?: string; } - - export function WikiLink({ target, label }: Props) { - return ( - - {label ?? target} - - ); - } - ``` - -- [ ] **Step 3: Create `ui/src/components/notes/MarkdownView.tsx`** - ```tsx - import { renderMarkdown } from "@/lib/markdown"; - import { WikiLink } from "./WikiLink"; - - export function MarkdownView({ source }: { source: string }) { - const parts = renderMarkdown(source); - return ( -
- {parts.map((p, i) => - p.kind === "html" ? ( -
- ) : ( - - ), - )} -
- ); - } - ``` - -- [ ] **Step 4: Write `ui/src/lib/__tests__/markdown.test.ts`** - ```ts - import { describe, it, expect } from "vitest"; - import { renderMarkdown } from "../markdown"; - - describe("renderMarkdown", () => { - it("parses headings + paragraphs", () => { - const parts = renderMarkdown("# Hello\n\nworld"); - expect(parts).toHaveLength(1); - expect(parts[0].content).toMatch(/

world/); - }); - it("strips YAML frontmatter", () => { - const parts = renderMarkdown("---\ntitle: hi\n---\n\nbody"); - expect(parts[0].content).toMatch(/

body/); - expect(parts[0].content).not.toMatch(/title:/); - }); - it("extracts plain wikilink", () => { - const parts = renderMarkdown("see [[target]]!"); - const link = parts.find((p) => p.kind === "wikilink"); - expect(link?.content).toBe("target"); - expect(link?.label).toBeUndefined(); - }); - it("extracts aliased wikilink and renders alias", () => { - const parts = renderMarkdown("see [[target|Alias]]!"); - const link = parts.find((p) => p.kind === "wikilink"); - expect(link?.content).toBe("target"); - expect(link?.label).toBe("Alias"); - }); - it("opens external links in new tab", () => { - const parts = renderMarkdown("[g](https://example.com)"); - expect(parts[0].content).toMatch(/target="_blank"/); - expect(parts[0].content).toMatch(/rel="noopener noreferrer"/); - }); - it("adds loading=lazy to images", () => { - const parts = renderMarkdown("![alt](/x.png)"); - expect(parts[0].content).toMatch(/loading="lazy"/); - }); - }); - ``` - -- [ ] **Step 5: Write `ui/src/components/notes/__tests__/MarkdownView.test.tsx`** - ```tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { MemoryRouter } from "react-router-dom"; - import { MarkdownView } from "../MarkdownView"; - - describe("MarkdownView", () => { - it("renders wikilinks as clickable router links with alias", () => { - render(); - const link = screen.getByRole("link", { name: "Alias" }) as HTMLAnchorElement; - expect(link.getAttribute("href")).toBe("/notes/target"); - }); - }); - ``` - -- [ ] **Step 6: Run + commit** - ```bash - cd ui && npm test -- markdown MarkdownView - cd .. - git add ui/src/lib/markdown.ts ui/src/lib/__tests__/markdown.test.ts ui/src/components/notes/MarkdownView.tsx ui/src/components/notes/WikiLink.tsx ui/src/components/notes/__tests__/MarkdownView.test.tsx - git commit -m "feat(ui): markdown renderer with wikilinks + aliased labels" - ``` - -### Task 5.2: Tree drawer + Link drawer - -**Files:** -- Create: `ui/src/components/notes/TreeDrawer.tsx`, `ui/src/components/notes/LinkPanel.tsx` -- Test: `ui/src/components/notes/__tests__/TreeDrawer.test.tsx` - -- [ ] **Step 1: Create `ui/src/components/notes/TreeDrawer.tsx`** - ```tsx - import { Link } from "react-router-dom"; - import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/dialog"; - import { useNotes } from "@/hooks/api/useNotes"; - import type { Note } from "@/types/api"; - - // Sheet in shadcn ships as Dialog-side; for our tree drawer a simple Dialog works. - // If shadcn-sheet primitive isn't available, this falls back to Dialog with side:"left" CSS. - - interface Props { project: string; open: boolean; onOpenChange: (v: boolean) => void; currentKey?: string; } - - function groupByFolder(notes: Note[]) { - const tree: Record = {}; - for (const n of notes) { - const parts = n.key.split("/"); - const folder = parts.length === 1 ? "" : parts.slice(0, -1).join("/"); - (tree[folder] ??= []).push(n); - } - return tree; - } - - export function TreeDrawer({ project, open, onOpenChange, currentKey }: Props) { - const { data = [] } = useNotes(project); - const grouped = groupByFolder(data); - const folders = Object.keys(grouped).sort(); - return ( - - - - - Notes - - -

- {folders.map((folder) => ( -
- {folder && ( -
- {folder}/ -
- )} - {grouped[folder] - .sort((a, b) => a.key.localeCompare(b.key)) - .map((n) => ( - onOpenChange(false)} - > - {n.key.split("/").pop()} - - ))} -
- ))} - {folders.length === 0 && ( -
No notes yet.
- )} -
- - - ); - } - ``` - - **Implementation note:** if `@/components/ui/dialog` doesn't export `Sheet` / `SheetContent` as named exports by default (shadcn's Dialog does, but Sheet is a separate file), run: - ```bash - cd ui && npx shadcn@latest add sheet --yes - ``` - Then import from `@/components/ui/sheet` in the file above. - -- [ ] **Step 2: Create `ui/src/components/notes/LinkPanel.tsx`** - ```tsx - import { Link } from "react-router-dom"; - import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; - import { useNotesGraph } from "@/hooks/api/useGraph"; - import { useMemo } from "react"; - - interface Props { project: string; open: boolean; onOpenChange: (v: boolean) => void; currentKey?: string; } - - export function LinkPanel({ project, open, onOpenChange, currentKey }: Props) { - const { data } = useNotesGraph(project); - const { inbound, outbound } = useMemo(() => { - if (!data || !currentKey) return { inbound: [] as string[], outbound: [] as string[] }; - const inb: string[] = []; - const out: string[] = []; - for (const e of data.edges) { - if (e.target === currentKey) inb.push(e.source); - if (e.source === currentKey) out.push(e.target); - } - return { inbound: Array.from(new Set(inb)), outbound: Array.from(new Set(out)) }; - }, [data, currentKey]); - - return ( - - - - - Links - - -
-
-

Inbound

- {inbound.length === 0 &&

} - {inbound.map((k) => ( - onOpenChange(false)} - className="block px-2 py-1 rounded hover:bg-[var(--color-surface-2)]" - > - {k} - - ))} -
-
-

Outbound

- {outbound.length === 0 &&

} - {outbound.map((k) => ( - onOpenChange(false)} - className="block px-2 py-1 rounded hover:bg-[var(--color-surface-2)]" - > - {k} - - ))} -
-
-
-
- ); - } - ``` - -- [ ] **Step 3: Test TreeDrawer** - ```tsx - // ui/src/components/notes/__tests__/TreeDrawer.test.tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { MemoryRouter } from "react-router-dom"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { http, HttpResponse } from "msw"; - import { server } from "@/test/msw"; - import { TreeDrawer } from "../TreeDrawer"; - - describe("TreeDrawer", () => { - it("renders grouped notes from API", async () => { - server.use( - http.get("/api/projects/_default/notes", () => - HttpResponse.json([ - { key: "architecture/jwt", content: "", tags: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, - { key: "decisions/drop-redis", content: "", tags: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, - { key: "intro", content: "", tags: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, - ]), - ), - ); - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - - {}} /> - - , - ); - // Labels appear async - await screen.findByText("jwt"); - expect(screen.getByText("architecture/")).toBeInTheDocument(); - expect(screen.getByText("decisions/")).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 4: Run + commit** - ```bash - cd ui && npm test -- notes/ - cd .. - git add ui/src/components/notes/TreeDrawer.tsx ui/src/components/notes/LinkPanel.tsx ui/src/components/notes/__tests__/TreeDrawer.test.tsx - git commit -m "feat(ui): TreeDrawer + LinkPanel sheets" - ``` - -### Task 5.3: NoteView + NotesLayout wiring - -**Files:** -- Modify: `ui/src/routes/notes/NotesLayout.tsx`, `ui/src/routes/notes/NoteView.tsx` -- Test: `ui/src/routes/notes/__tests__/NoteView.test.tsx` - -- [ ] **Step 1: Rewrite `ui/src/routes/notes/NotesLayout.tsx`** - ```tsx - import { Outlet, useParams } from "react-router-dom"; - import { useState } from "react"; - import { TreeDrawer } from "@/components/notes/TreeDrawer"; - import { LinkPanel } from "@/components/notes/LinkPanel"; - import { useProjectStore } from "@/stores/project"; - import { useHotkey } from "@/hooks/useHotkey"; - - export default function NotesLayout() { - const project = useProjectStore((s) => s.slug); - const { key } = useParams(); - const [treeOpen, setTreeOpen] = useState(false); - const [linksOpen, setLinksOpen] = useState(false); - - useHotkey("mod+/", () => setTreeOpen((v) => !v)); - useHotkey("mod+l", () => setLinksOpen((v) => !v)); - - return ( -
- - - - {!key && ( -
- Open the tree (⌘/) or search (⌘K) to select a note. -
- )} -
- ); - } - ``` - -- [ ] **Step 2: Rewrite `ui/src/routes/notes/NoteView.tsx`** - ```tsx - import { useParams } from "react-router-dom"; - import { MarkdownView } from "@/components/notes/MarkdownView"; - import { useNote } from "@/hooks/api/useNotes"; - import { useProjectStore } from "@/stores/project"; - import { formatRelativeTime } from "@/lib/format"; - - export default function NoteView() { - const { key } = useParams(); - const project = useProjectStore((s) => s.slug); - const { data: note, isLoading, error } = useNote(project, key); - - if (isLoading) { - return
Loading…
; - } - if (error || !note) { - return ( -
-

Note not found

-

{key}

-
- ); - } - - return ( -
-
-

{note.key.split("/").pop()}

-
- {note.key} · updated {formatRelativeTime(new Date(note.updated_at).getTime())} - {note.author && ` · by ${note.author}`} -
-
- -
- ); - } - ``` - -- [ ] **Step 3: Write test** - ```tsx - // ui/src/routes/notes/__tests__/NoteView.test.tsx - import { describe, it, expect } from "vitest"; - import { render, screen } from "@testing-library/react"; - import { MemoryRouter, Route, Routes } from "react-router-dom"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - import { http, HttpResponse } from "msw"; - import { server } from "@/test/msw"; - import NoteView from "../NoteView"; - - describe("NoteView", () => { - it("renders note content + metadata", async () => { - server.use( - http.get("/api/projects/_default/notes/jwt", () => - HttpResponse.json({ - key: "jwt", content: "# JWT rotation\n\nbody", author: "claude", - tags: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - }), - ), - ); - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - - } /> - - , - ); - await screen.findByRole("heading", { name: /jwt rotation/i }); - expect(screen.getByText(/by claude/i)).toBeInTheDocument(); - }); - }); - ``` - -- [ ] **Step 4: Run + build + commit** - ```bash - cd ui && npm test -- notes && npm run build - cd .. - git add ui/src/routes/notes/ ui/dist/ - git commit -m "feat(ui): NotesLayout + NoteView with ⌘/ tree + ⌘L links" - ``` - -### Task 5.4: NoteEditor + NotesSearch (out-of-scope reduction) - -**Files:** -- Create: `ui/src/routes/notes/NoteEditor.tsx`, `ui/src/routes/notes/NotesSearch.tsx` - -For v1, keep NoteEditor minimal: a `