diff --git a/.gitignore b/.gitignore index 3554a7fd..501ec1ff 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,11 @@ out/ .DS_Store Thumbs.db -# Screenshots/images +# Screenshots/images — ignored at the repo root (we accidentally collect a lot +# of them there), but ALLOWED under docs/design-briefs/ so design iteration +# artifacts (mockups, critique notes) stay versioned with the feature. *.png +!docs/design-briefs/**/*.png # Python __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..050a77dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,153 @@ +# Changelog + +All notable changes to Claude Code Karma are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] — 2026-05-19 + +The "tickets" release. Karma now understands work as well as activity: +attach Linear / Jira / GitHub Issues to Claude Code sessions, then see +the dashboard pivot around tickets the same way it pivots around projects. + +### Added + +- **Session ↔ Ticket linking** for Linear, Jira, and GitHub Issues. + Karma stays read-only — it stores the link and caches metadata, never + writes back to your ticket provider. +- **Three ways to link a session to a ticket:** + - **Dashboard paste** — open a session, paste a ticket URL or key into + the Tickets section, done. + - **Slash command / skill** — `/link-ticket-to-session ABC-123` in a + Claude Code session. The agent fetches title + status via your + Linear / Atlassian / GitHub MCP server (if installed) and posts the + link to karma. Honors `KARMA_API_URL` for non-default hosts. + - **Branch-name auto-detect** (opt-in hook) — when you start a session + on `feat/LINEAR-123-foo`, the link is created automatically. Silent + on every failure; never blocks `SessionStart`. +- **`/tickets`** index page with provider, project, and search filters. +- **`/tickets/{provider}/{key}`** detail page listing every linked + session for a ticket, with live-session enrichment for unindexed + active sessions. +- **Tickets tab on every project page** showing every ticket touched by + any session in that project — and across **every checkout of the same + repo** (worktrees, subdir CWDs, submodules) via the new `git_identity` + column. So a session linked from `claude-karma/frontend/` shows the + ticket on the main `claude-karma` project too. +- **GitHub key heuristic** — tickets like `owner/repo#42` surface under + any project whose `git_identity` is `owner/repo`, even if no local + session has linked them yet. Useful when a teammate has linked the + ticket on a different machine. +- **Cross-encoded ticket aggregation** powered by `projects.git_identity` + (canonical `owner/repo` lowercase, derived from `git remote get-url + origin` at index time). Lets karma treat every checkout of the same + repo as one logical project for ticket views. + +### Changed + +- **Frontend route param renamed `[project_slug]` → `[project_id]`.** + The route accepts either form (slug or `encoded_name`), so existing + URLs and bookmarks continue to work. The new name is honest about + what it accepts. +- **All API endpoints with a project filter now accept either form** + uniformly via the new `safely_resolve_project()` helper. Applied to + `/tickets`, `/skills`, `/skills/usage`, `/commands` (5 endpoints), + `/agents` filters, and `/live-sessions/project/{id}`. Endpoints that + previously matched `encoded_name` exactly and returned an empty list + for slugs now resolve cleanly. + +### Fixed + +- **`/projects → card → Tickets tab` showed empty** for sessions linked + via the dashboard. Cause: project cards link via slug, the tickets + API matched `encoded_name` exactly. Fix: unified data flow with a + clean "slug at the boundary, encoded_name inside" architecture across + every project-by-identifier endpoint. Locked in by a regression test. +- **GitHub PRs were stored with `/issues/N` URLs** instead of `/pull/N`. + Cause: the ticket parser hard-coded `/issues/` in the canonical URL + it built, throwing away which path segment the input URL had used. + Fix: parser now captures `(?Pissues|pull)` and uses the + captured segment in the canonical URL. The frontend grew a small + `PR` pill (with GitHub's pull-request glyph) next to the GH provider + badge so issues and PRs are visually distinguishable everywhere. + Old rows self-heal on re-link; users wanting immediate repair can hit + the new `POST /admin/repair-github-urls` endpoint (see Upgrading). + +### Internal + +- **Schema v12.** Adds `projects.git_identity TEXT` + index. Migration + runs automatically on first start (idempotent against any phantom + column from out-of-band branches) and nudges session mtimes so the + periodic indexer backfills `git_identity` within ~5 minutes. To + populate immediately: `POST /admin/reindex`. +- **New service module** `api/services/git_identity.py` — + `normalize_git_url()` (pure parser, handles https / ssh / scp-style + with or without `.git`) and `read_git_identity()` (timeout-guarded + `git remote get-url origin` shellout). +- **New helper** `safely_resolve_project()` in `api/routers/projects.py` + — filter-friendly variant of `resolve_project_identifier` that returns + the raw input verbatim on unknown identifiers (so downstream queries + yield empty lists, not 404s). +- **New frontend helper** `src/lib/utils/project-url.ts` — + `projectHref()` + `projectHrefFromSession()` centralize the + `slug || encoded_name` policy in one place. Migrated 5 call sites + (`GlobalSessionCard`, `LiveSessionsTerminal`, `LiveSessionsSection`, + `CommandPalette`, plans page, `ConversationOverview`). + +### Tests + +- 1580 passing (was 1474). Added: 40 ticket endpoint tests, 23 + `normalize_git_url` parser tests, 7 `safely_resolve_project` / + `resolve_project_identifier` unit tests, plus parser, enrichment, + branch-detector hook (299 LOC), and schema-migration regression tests. + +### Upgrading from 0.1.x + +No manual steps required. On first start: + +1. Schema migrates v11 → v12 automatically. The migration is idempotent + and safe to re-run. +2. `projects.git_identity` is backfilled by the periodic indexer (default + interval: 5 minutes). To trigger immediately: + `curl -X POST http://localhost:8000/admin/reindex`. +3. The route rename `[project_slug]` → `[project_id]` is internal — + existing URLs continue to work. + +**Optional: repair stale GitHub PR URLs.** If you linked GitHub PRs in +0.1.x, their stored URLs point at `/issues/N` even though they were +PRs. Links still work (GitHub redirects), but the new `PR` pill won't +show. Rows self-heal on re-link, or you can repair them in one shot: + +```bash +curl -X POST http://localhost:8000/admin/repair-github-urls +# {"status":"ok","rewritten":N} +``` + +The repair is conservative — it only rewrites rows whose +`status='MERGED'` (a state unique to PRs). Open or closed-unmerged PRs +remain ambiguous from cached data alone and self-heal on next re-link. + +If you used the syncthing prototype in an earlier branch, the +`sync_*` tables and any `jayantdevkar-claude-code-karma`-style project +rows are left over from that prototype (not part of this branch's +schema). Safe to delete manually if you want a clean dashboard: + +```sql +DROP TABLE IF EXISTS sync_subscriptions; +DROP TABLE IF EXISTS sync_removed_members; +DROP TABLE IF EXISTS sync_events; +DROP TABLE IF EXISTS sync_projects; +DROP TABLE IF EXISTS sync_members; +DROP TABLE IF EXISTS sync_teams; +DELETE FROM projects WHERE encoded_name NOT LIKE '-%'; +``` + +--- + +## [0.1.0] — Earlier + +Initial public release. The first 224 stars 🌟. + +See [git history](https://github.com/JayantDevkar/claude-code-karma/commits/main) +for changes prior to the introduction of this changelog. diff --git a/FEATURES.md b/FEATURES.md index 09e50325..2ff48088 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -14,6 +14,7 @@ - [Agents](#agents) - [Skills](#skills) - [Plans](#plans) +- [Tickets](#tickets) - [Settings](#settings) - [Additional Features](#additional-features) @@ -442,6 +443,108 @@ Browse implementation plans from Claude Code plan mode sessions. --- +## Tickets + +**Routes:** `/tickets`, `/tickets/[provider]/[external_key]` + +Attach Claude Code sessions to tickets in Linear, Jira, or GitHub Issues +and audit what work was done for each ticket across sessions. Karma is +**read-only** — it stores the link and caches metadata (title, status) +but never writes back to your ticket provider. + +### Three Ways to Link + +| Path | Where | How | +|------|-------|-----| +| **Dashboard paste** | Tickets section on any session page | Paste a URL or key (e.g. `LINEAR-123`, `octocat/repo#42`, full Linear/Jira/GitHub URL). Karma stores the link; if you previously linked the same ticket from another session, metadata is reused. | +| **Slash command / skill** | Inside any Claude Code session | `/link-ticket-to-session ABC-123` or natural language ("link this session to LINEAR-42"). The agent uses your Linear/Atlassian/GitHub MCP server to fetch title + status, then POSTs the link to karma. If no MCP is installed, the link still works — just without cached title/status. | +| **Branch-name auto-detect** | Hook fired at `SessionStart` | Opt-in hook (`ticket_branch_detector.py`) watches your git branch. Branches matching configured regex patterns (e.g. `feat/LINEAR-123-foo`) auto-create the link silently. Failures never block `SessionStart`. | + +See [SETUP.md → Tier 4](SETUP.md#tier-4-ticket-linking-optional) +for installation steps. + +### Tickets Index (`/tickets`) + +- **Provider columns** — Linear / Jira / GitHub badges with brand colors +- **Search** — Filter by external key or title substring +- **Provider filter** — Show only Linear / Jira / GitHub tickets +- **Project filter** — Restrict to tickets touched by sessions in a + specific project +- **Session counts** — How many sessions each ticket spans +- **Last linked** — Most recent link timestamp per ticket + +### Ticket Detail (`/tickets/[provider]/[external_key]`) + +- **Header** — Ticket key + cached title + status badge + external link + to provider +- **Linked sessions list** — Every session this ticket is attached to, + with title, project, and timestamp +- **Live-session enrichment** — Sessions still being written (not yet + indexed) are surfaced via the live-sessions tracker so you don't miss + in-flight work +- **Orphan-safe** — Links to sessions that aren't in the index yet still + appear (e.g. just-started sessions); their fields populate as soon as + the indexer catches up +- **GitHub-style keys** — Keys containing `/` and `#` (like + `owner/repo#42`) are URL-encoded transparently + +### Project Tickets Tab + +Every project page now has a **Tickets** tab showing every ticket touched +by any session in that project — and **across every checkout of the same +git repo**. This means a ticket linked from a session inside +`claude-karma/frontend/` also shows up on the main `claude-karma` +project's Tickets tab (and on `claude-karma/docs/design/`, any worktree, +etc.). + +The aggregation key is `git_identity` (canonical `owner/repo` +lowercase), derived at index time from each project's `git remote +get-url origin`. For projects without a local git remote (sync-imported +or freshly-`git init`'d), the tab falls back to per-encoded_name match. + +For GitHub specifically, tickets whose `external_key` starts with the +project's `git_identity` (e.g. `octocat/repo#42` under a project with +`git_identity=octocat/repo`) surface on the tab **even if no local +session has linked them yet** — useful when a teammate has linked the +ticket on a different machine. + +### Session Tickets Section + +On any session page, the Tickets section provides: + +- **Link input** with five UI states: idle, valid ref, fetching, linked, + error — clear feedback at every step +- **Linked ticket badges** with provider color, key, title preview, and + status dot +- **Unlink** action with optimistic update + undo toast (6s grace period + before the DELETE actually fires) +- **External link** to open the ticket in its provider + +### Link Source Precedence + +When the same `(session, ticket)` pair is touched by multiple link paths, +karma upgrades but never downgrades the `link_source`: + +``` +slash_command > dashboard > branch +``` + +So a branch auto-link can be upgraded to `slash_command` later, but +re-running the branch hook won't override an explicit dashboard link. + +### Schema & Storage + +- **Tables:** `tickets` (unique on `(provider, external_key)`), + `session_tickets` (unique on `(session_uuid, ticket_id)` plus a + partial unique index on `(session_slug, ticket_id)` for resumed-session + dedup) +- **Metadata cap:** 64 KB per ticket (enforced by SQL CHECK constraint) +- **Orphan cleanup:** Background asyncio task removes + `session_tickets` rows whose `session_uuid` never materialized after + a 7-day TTL + +--- + ## Settings **Route:** `/settings` diff --git a/README.md b/README.md index 8e59d184..63442afe 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,22 @@ Track which skills are invoked across sessions, grouped by plugin or shown indiv Skill Detail with History

+### Ticket Linking (Linear / Jira / GitHub Issues) + +Attach your Claude Code sessions to the tickets they were about. Karma stays read-only — it stores the link and caches the title/status, but never writes back to your ticket provider. + +Three ways to link: + +- **Paste a URL or key** into the Tickets section on any session page +- **Type `/link-ticket-to-session ABC-123`** (or ask the agent in natural language) in any Claude Code session — uses your Linear / Atlassian / GitHub MCP server to fetch the title +- **Auto-detect from your branch name** — opt-in `SessionStart` hook that watches for keys like `feat/LINEAR-123-foo` and links silently in the background + +Then browse: + +- A `/tickets` index showing every ticket touched, filterable by provider and project +- A ticket detail page listing every session linked to a given ticket +- A **Tickets tab on every project page** that aggregates across all checkouts of the same git repo — so a ticket linked from `claude-karma/frontend/` also shows on the main `claude-karma` project + ### And More - **Plans Browser** — View implementation plans and their execution status diff --git a/SETUP.md b/SETUP.md index 5a5ee28b..c4e0e677 100644 --- a/SETUP.md +++ b/SETUP.md @@ -6,15 +6,16 @@ ## What You'll Get -Claude Code Karma installs in **3 progressive tiers**. Start with the core dashboard, then add live monitoring and smart titles as needed. +Claude Code Karma installs in **4 progressive tiers**. Start with the core dashboard, then add live monitoring, smart titles, and ticket-linking workflows as needed. | Tier | Components | Dashboard Features | Installation Time | |------|-----------|-------------------|-------------------| -| **1: Core Dashboard** | API + Frontend | Browse projects, view sessions, analytics | ~5 min | +| **1: Core Dashboard** | API + Frontend | Browse projects, view sessions, analytics, **`/tickets` page** (works empty) | ~5 min | | **2: Live Monitoring** | + Live Tracker Hook | Real-time session indicators, recently ended | +2 min | | **3: Smart Titles** | + Title Generator Hook | Human-readable session names | +2 min | +| **4: Auto-Link Tickets** | + `link-ticket-to-session` skill, optional branch hook | Slash command + natural-language linking, optional auto-link from git branch name | +2 min | -**You can stop after Tier 1.** Tiers 2 and 3 are optional enhancements installed independently. +**You can stop after Tier 1.** Tiers 2–4 are optional enhancements installed independently. Tier 4 only adds *workflows* for creating links; the ticket pages themselves (`/tickets`, project Tickets tab, session Tickets section) all work in Tier 1 — you can paste a URL on any session page without any hooks or skills installed. --- @@ -193,6 +194,8 @@ Open http://localhost:5173 in your browser. You should see the Claude Code Karma | Plugins browser | `/plugins` | Works | | Tools browser | `/tools` | Works | | Sessions browser | `/sessions` | Works | +| Tickets index | `/tickets` | Works (empty until you link) | +| Ticket detail | `/tickets/[provider]/[key]` | Works | | Archived sessions | `/archived` | Works | | About page | `/about` | Works | | Settings management | `/settings` | Works | @@ -555,6 +558,120 @@ curl http://localhost:8000/projects/YOUR-PROJECT-NAME | python3 -m json.tool | g --- +## Tier 4: Auto-Link Tickets (Optional) + +> Tier 4 adds **workflows** for linking sessions to tickets — a slash command/skill and an optional auto-detect hook. The Tickets page and related UI (`/tickets`, project Tickets tab, session Tickets section) are part of Tier 1 and work without any of Tier 4 installed. You can already paste a URL on a session page and link it. Tier 4 just makes it more ergonomic. + +Links Claude Code sessions to tickets in Linear / Jira / GitHub Issues, so the karma dashboard can show "what work was done for ticket X" across sessions. + +**What Tier 4 adds:** + +- A `/link-ticket-to-session` **slash command / skill** that uses your existing Linear / Atlassian / GitHub MCP server (if installed) to fetch the ticket title at link time +- An **opt-in `SessionStart` hook** that auto-links when the current git branch matches a configured pattern (e.g. `feat/LINEAR-123-foo`) + +**Architecture:** + +- Karma is read-only — it never writes to Linear/Jira/GitHub. +- Metadata (title, status) comes from the agent's MCP servers, not from karma's backend, so karma never needs provider credentials. +- The skill works **without** any MCP — the link is created either way; without an MCP installed, the cached title/status is just blank until refreshed. +- The skill honors a `KARMA_API_URL` env var for users running the API on a non-default port or remote host. Default fallback: `http://localhost:8000`. + +### Step 11: Install the Skill (Recommended) + +**What:** Adds the `link-ticket-to-session` skill so you can link the current session — either by typing `/link-ticket-to-session ` explicitly, or by asking the agent in natural language ("link this session to LINEAR-123"). The skill's instructions tell the agent to fetch the title via your installed MCP server (Linear/Jira/GitHub) and POST the link to karma. + +Skills are directory-shaped artifacts under `~/.claude/skills//SKILL.md`. + +**Symlink (recommended for development):** + +```bash +mkdir -p ~/.claude/skills +ln -sf "$(cd skills/link-ticket-to-session && pwd)" ~/.claude/skills/link-ticket-to-session +``` + +**Copy (for standalone installation):** + +```bash +mkdir -p ~/.claude/skills +cp -R skills/link-ticket-to-session ~/.claude/skills/ +``` + +**Verify** by starting any Claude Code session and typing `/link-ticket-to-session https://linear.app/.../issue/ABC-123`. The agent should fetch the title via your Linear MCP (if installed) and POST the link to karma. You should also be able to phrase it naturally — e.g. "link this session to LINEAR-123" — and the agent should reach for the same skill. + +**No MCP installed?** That's fine. The skill posts the link to karma regardless. The cached title/status will just be empty — you can still see the linked session under the ticket on the dashboard, and you can refresh the metadata later via `PUT /tickets/{provider}/{key}`. + +**Custom karma URL?** If your API runs on a non-default port or a remote host, set `KARMA_API_URL` in your shell or in `~/.claude/settings.json` env (e.g. `KARMA_API_URL=http://karma.internal:9000`). The skill reads it on each invocation. + +**Note:** The skill's `description` is intentionally narrow so it only fires on explicit linking requests. Casual mentions of a ticket key in conversation will NOT auto-trigger the skill. + +### Step 12: Enable Branch-Name Auto-Detection (Optional) + +**What:** A `SessionStart` hook that watches the git branch and auto-links the session when the branch name matches a configured pattern. + +**Why:** Zero-friction linking when your team's branch convention encodes ticket keys (e.g., `feat/LINEAR-123-fix-login`). + +**Install the script:** + +```bash +ln -sf "$(cd hooks && pwd)/ticket_branch_detector.py" ~/.claude/hooks/ticket_branch_detector.py +chmod +x hooks/ticket_branch_detector.py +``` + +**Configure the patterns** in `~/.claude_karma/config.json`: + +```json +{ + "branch_detect_enabled": true, + "ticket_branch_patterns": [ + { "regex": "(?P[A-Z][A-Z0-9_]+-\\d+)", "provider": "linear" } + ] +} +``` + +- `branch_detect_enabled` is `false` by default — set it to `true` to opt in. +- `regex` is a Python regex. If it has a `key` named group, that group's match is used as the ticket key; otherwise the entire match is used. +- `provider` must be `linear`, `jira`, or `github`. + +**Register the hook** in `~/.claude/settings.json` under `hooks.SessionStart` (alongside `live_session_tracker.py` if you installed Tier 2): + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": ".*", + "hooks": [ + { "type": "command", "command": "python3 ~/.claude/hooks/ticket_branch_detector.py" } + ] + } + ] + } +} +``` + +The hook is silent on every failure (no git, no match, karma unreachable) — it never blocks `SessionStart`. Errors land in `~/.claude_karma/logs/ticket_branch_detector.log`. + +### Step 13: Verify Tier 4 + +**Skill verification:** + +1. Start any Claude Code session +2. Type `/link-ticket-to-session https://linear.app/.../issue/ABC-123` (substitute a real ticket from your provider) +3. The agent should respond with a one-line confirmation like + `Linked session to LINEAR-123 (Fix login bug) — open at https://linear.app/...` +4. Refresh the karma dashboard → the session page should show the ticket in the Tickets section, and `/tickets` should list it + +**Branch-detect verification:** + +1. Create or check out a branch matching your pattern (e.g. `feat/LINEAR-123-test`) +2. Start a new Claude Code session in that working directory +3. After `SessionStart` fires, hit `curl http://localhost:8000/sessions//tickets` — you should see a link with `"link_source":"branch"` +4. Errors during detection land in `~/.claude_karma/logs/ticket_branch_detector.log` (silent on success) + +> **Agent notes:** If the skill is installed but doesn't trigger, ensure `~/.claude/skills/link-ticket-to-session/SKILL.md` exists and is readable. If the branch hook doesn't link, check the log file and confirm `branch_detect_enabled: true` in the config. + +--- + ## Hook Configuration Reference ### Complete Configuration (All Tiers) @@ -669,6 +786,30 @@ The full `~/.claude/settings.json` for Tier 2 + 3: If you only want live tracking without title generation, use the same configuration as [Step 6](#step-6-register-hook-events) — omit the `session_title_generator.py` entry from `SessionEnd`. +### Tier 4 Addition (Branch Detector) + +If you installed the branch detector hook in Tier 4, add it to `SessionStart` alongside `live_session_tracker.py`: + +```json +"SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.claude/hooks/live_session_tracker.py", + "timeout": 5000 + }, + { + "type": "command", + "command": "python3 ~/.claude/hooks/ticket_branch_detector.py" + } + ] + } +] +``` + +The branch detector has no timeout entry because it self-times-out at 2 seconds and is silent on every failure. + ### Removing a Hook To remove a hook after installation: @@ -677,6 +818,7 @@ To remove a hook after installation: |------|--------| | Live tracker | Delete all `live_session_tracker.py` entries from `"hooks"` in settings.json | | Title generator | Delete the `session_title_generator.py` entry from `"SessionEnd"` | +| Branch detector | Delete the `ticket_branch_detector.py` entry from `"SessionStart"`, or just set `"branch_detect_enabled": false` in `~/.claude_karma/config.json` to keep the hook installed but inert | Then optionally clean up the script: ```bash @@ -847,6 +989,15 @@ Run through this after setup to confirm everything works. - [ ] Backfill complete: `cd api && python3 scripts/backfill_titles.py` ran without errors - [ ] Existing sessions titled: Old sessions now have titles instead of just slugs +### Tier 4: Auto-Link Tickets + +- [ ] Skill installed: `ls ~/.claude/skills/link-ticket-to-session/SKILL.md` +- [ ] Skill triggers: `/link-ticket-to-session ` in a session posts a link visible at `http://localhost:8000/sessions//tickets` +- [ ] Branch hook installed (optional): `ls ~/.claude/hooks/ticket_branch_detector.py` +- [ ] Branch hook registered (optional): `grep "ticket_branch_detector" ~/.claude/settings.json` +- [ ] Branch config exists (optional): `cat ~/.claude_karma/config.json | grep branch_detect_enabled` +- [ ] Branch auto-link works (optional): check out `feat/LINEAR-123-test`, start a new session, verify a link with `"link_source":"branch"` is created + --- ## Troubleshooting @@ -1005,10 +1156,40 @@ git pull origin main cd api && pip install -r requirements.txt cd frontend && npm install -# Reindex SQLite if schema changed +# Reindex SQLite if schema changed (always safe — incremental + idempotent) curl -X POST http://localhost:8000/admin/reindex ``` +### Schema Migrations + +Karma applies schema migrations automatically on first start after each +update. Migrations are incremental (only the steps you haven't run yet) +and idempotent (safe to re-run). The current schema is **v12** and adds: + +| Version | Adds | +|--------:|------| +| **v12** | `projects.git_identity` column (canonical `owner/repo` lowercase) for cross-checkout ticket aggregation. Backfilled by the periodic indexer within ~5 min, or immediately via `POST /admin/reindex`. | +| **v11** | `tickets` + `session_tickets` tables for ticket linking. | +| v10 | Skill/command linkage refresh. | +| v9 | `invocation_source` columns + worktree session resolution. | +| v8 | `subagent_skills`, `subagent_commands` tables. | +| ≤ v7 | Earlier schema foundations. | + +If you ran an out-of-branch prototype (e.g. the syncthing sync v4 +branch) and have leftover `sync_*` tables or non-dash-prefixed project +rows you want to clean up: + +```sql +-- One-shot cleanup of syncthing prototype leftovers +DROP TABLE IF EXISTS sync_subscriptions; +DROP TABLE IF EXISTS sync_removed_members; +DROP TABLE IF EXISTS sync_events; +DROP TABLE IF EXISTS sync_projects; +DROP TABLE IF EXISTS sync_members; +DROP TABLE IF EXISTS sync_teams; +DELETE FROM projects WHERE encoded_name NOT LIKE '-%'; +``` + --- ## Getting Help diff --git a/api/db/indexer.py b/api/db/indexer.py index 4958046c..6e78e4c1 100644 --- a/api/db/indexer.py +++ b/api/db/indexer.py @@ -720,10 +720,14 @@ def _update_project_summaries(conn: sqlite3.Connection) -> None: """ Update the projects summary table from aggregated session data. Uses INSERT OR REPLACE to avoid race condition between DELETE and INSERT. - Computes slug and display_name for URL beautification. + Computes slug and display_name for URL beautification, and reads + `git_identity` (canonical owner/repo) from each project_path so that + cross-cutting views can aggregate worktrees, subdir checkouts, and + other encoded_name variants that represent the same logical repo. """ from pathlib import Path + from services.git_identity import read_git_identity from utils import compute_project_slug rows = conn.execute( @@ -751,14 +755,24 @@ def _update_project_summaries(conn: sqlite3.Connection) -> None: slug = compute_project_slug(encoded_name, project_path) display_name = Path(project_path).name if project_path else encoded_name + git_identity = read_git_identity(project_path) conn.execute( """ INSERT OR REPLACE INTO projects - (encoded_name, project_path, slug, display_name, session_count, last_activity) - VALUES (?, ?, ?, ?, ?, ?) + (encoded_name, project_path, slug, display_name, + session_count, last_activity, git_identity) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (encoded_name, project_path, slug, display_name, session_count, last_activity), + ( + encoded_name, + project_path, + slug, + display_name, + session_count, + last_activity, + git_identity, + ), ) conn.commit() diff --git a/api/db/schema.py b/api/db/schema.py index 59c7f0a8..6caa74ef 100644 --- a/api/db/schema.py +++ b/api/db/schema.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -SCHEMA_VERSION = 10 +SCHEMA_VERSION = 12 SCHEMA_SQL = """ -- Schema versioning @@ -210,12 +210,77 @@ display_name TEXT, session_count INTEGER DEFAULT 0, last_activity TEXT, + -- git_identity: canonical `owner/repo` lowercase, derived from + -- `git -C project_path config --get remote.origin.url`. NULL when + -- the project has no local git remote (sync-imported, never inited). + -- Used by ticket queries to aggregate across encoded_names that + -- represent the same logical repo (worktrees, subdir projects, etc.). + git_identity TEXT, updated_at TEXT DEFAULT (datetime('now')) ); +CREATE INDEX IF NOT EXISTS idx_projects_git_identity ON projects(git_identity); + CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug); """ +# Ticket tables — extracted into a separate constant so they can be applied +# UNCONDITIONALLY at ensure_schema() time, regardless of the recorded +# SCHEMA_VERSION. This protects against cross-branch DB drift: if a karma +# DB has been used on a parallel branch whose linear SCHEMA_VERSION ran +# ahead of ours, the early-return version gate would otherwise skip our +# v11 migration block and leave us with no ticket tables. CREATE TABLE IF +# NOT EXISTS makes the unconditional run safe on every install path. +_TICKETS_SCHEMA_SQL = """ +-- Ticket registry: de-duped per (provider, external_key). +-- Populated by the agent (via MCP) at slash-command link time, or empty +-- (URL-only) when the link comes from the branch-detect hook or dashboard. +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK (provider IN ('linear','jira','github')), + external_key TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT, + status TEXT, + metadata_json TEXT CHECK (metadata_json IS NULL OR length(metadata_json) <= 65536), + metadata_updated_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, external_key) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_provider ON tickets(provider); + +-- Many-to-many: a session can link to many tickets; a ticket can be linked +-- from many sessions. No FK on session_uuid because the branch-detect hook +-- writes at SessionStart, possibly before the JSONL indexer has created the +-- sessions row. Orphans are reaped periodically (see api/main.py lifespan). +CREATE TABLE IF NOT EXISTS session_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_uuid TEXT NOT NULL, + session_slug TEXT, + ticket_id INTEGER NOT NULL, + link_source TEXT NOT NULL CHECK (link_source IN ('branch','slash_command','dashboard')), + linked_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + UNIQUE(session_uuid, ticket_id) +); + +CREATE INDEX IF NOT EXISTS idx_session_tickets_session ON session_tickets(session_uuid); +CREATE INDEX IF NOT EXISTS idx_session_tickets_slug ON session_tickets(session_slug); +CREATE INDEX IF NOT EXISTS idx_session_tickets_ticket ON session_tickets(ticket_id); + +-- Partial unique index dedupes links across resumed sessions (resumes share +-- a slug but get fresh UUIDs). Skipped when slug isn't known at write time; +-- per-UUID UNIQUE above is the fallback. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_session_tickets_slug_ticket + ON session_tickets(session_slug, ticket_id) + WHERE session_slug IS NOT NULL; +""" + +# Keep ticket tables in the canonical fresh-install schema too, so a +# brand-new DB still gets everything in one shot through SCHEMA_SQL. +SCHEMA_SQL = SCHEMA_SQL + _TICKETS_SCHEMA_SQL + def ensure_schema(conn: sqlite3.Connection) -> None: """ @@ -223,6 +288,14 @@ def ensure_schema(conn: sqlite3.Connection) -> None: Idempotent — safe to call on every startup. """ + # Cross-branch safety: always run the ticket-tables block. If a karma + # DB has a recorded SCHEMA_VERSION higher than ours (e.g., from a + # parallel branch with more migrations), the early-return below would + # otherwise skip our v11 work and leave ticket endpoints broken. The + # CREATE TABLE IF NOT EXISTS statements make this a no-op when the + # tables already exist. + conn.executescript(_TICKETS_SCHEMA_SQL) + # Check current version try: row = conn.execute("SELECT MAX(version) FROM schema_version").fetchone() @@ -426,6 +499,72 @@ def ensure_schema(conn: sqlite3.Connection) -> None: # Nudge mtime to force re-index of all sessions conn.execute("UPDATE sessions SET jsonl_mtime = jsonl_mtime - 1") + if current_version < 11: + logger.info("Migrating → v11: adding tickets + session_tickets tables") + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK (provider IN ('linear','jira','github')), + external_key TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT, + status TEXT, + metadata_json TEXT CHECK (metadata_json IS NULL OR length(metadata_json) <= 65536), + metadata_updated_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, external_key) + ); + + CREATE INDEX IF NOT EXISTS idx_tickets_provider ON tickets(provider); + + CREATE TABLE IF NOT EXISTS session_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_uuid TEXT NOT NULL, + session_slug TEXT, + ticket_id INTEGER NOT NULL, + link_source TEXT NOT NULL CHECK (link_source IN ('branch','slash_command','dashboard')), + linked_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + UNIQUE(session_uuid, ticket_id) + ); + + CREATE INDEX IF NOT EXISTS idx_session_tickets_session ON session_tickets(session_uuid); + CREATE INDEX IF NOT EXISTS idx_session_tickets_slug ON session_tickets(session_slug); + CREATE INDEX IF NOT EXISTS idx_session_tickets_ticket ON session_tickets(ticket_id); + + CREATE UNIQUE INDEX IF NOT EXISTS uniq_session_tickets_slug_ticket + ON session_tickets(session_slug, ticket_id) + WHERE session_slug IS NOT NULL; + """) + + if current_version < 12: + logger.info( + "Migrating → v12: adding projects.git_identity for cross-encoded " + "ticket aggregation" + ) + # The minimum-fixture schema test (test_migration_from_v10) seeds + # only schema_version and skips SCHEMA_SQL, so projects/sessions + # may not exist. PRAGMA table_info returns 0 rows in that case + # and we'd ALTER a missing table. Production always has both + # tables — they're in SCHEMA_SQL since v1. + projects_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()} + if projects_cols: + # Idempotent: some DBs already have the column from an + # out-of-band ALTER on a parallel branch (e.g. the sync-v4 + # prototype worktree). + if "git_identity" not in projects_cols: + conn.execute("ALTER TABLE projects ADD COLUMN git_identity TEXT") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_projects_git_identity " + "ON projects(git_identity)" + ) + # Nudge mtimes so the next periodic indexer pass re-runs + # _update_project_summaries for every project and populates + # the new column. Matches the v8/v9/v10 backfill pattern. + # Gated by the same projects-table guard because if projects + # doesn't exist, sessions doesn't either in the minimal fixture. + conn.execute("UPDATE sessions SET jsonl_mtime = jsonl_mtime - 1") + # Record version conn.execute( "INSERT OR REPLACE INTO schema_version (version) VALUES (?)", diff --git a/api/db/ticket_queries.py b/api/db/ticket_queries.py new file mode 100644 index 00000000..7e890108 --- /dev/null +++ b/api/db/ticket_queries.py @@ -0,0 +1,439 @@ +""" +SQLite queries for ticket linking. + +Reads use a read-only connection (`sqlite_read()` / `create_read_connection()`). +Writes use the writer singleton (`get_writer_db()`), which serializes +mutations via SQLite's WAL writer lock. All write functions wrap their +multi-statement work in an explicit transaction. + +See: docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md +""" + +from __future__ import annotations + +import logging +import sqlite3 +from typing import Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Writes +# --------------------------------------------------------------------------- + + +def upsert_ticket( + conn: sqlite3.Connection, + *, + provider: str, + external_key: str, + url: str, +) -> int: + """Insert-or-update the tickets row keyed on (provider, external_key). + + Returns the row's id. URL is refreshed on conflict so a later link with + a canonical URL replaces an earlier search-fallback URL. + """ + row = conn.execute( + """ + INSERT INTO tickets (provider, external_key, url, first_seen_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT (provider, external_key) DO UPDATE + SET url = excluded.url + RETURNING id + """, + (provider, external_key, url), + ).fetchone() + return row["id"] + + +# Numeric precedence for link_source. Higher = more trustworthy. +# slash_command > dashboard > branch. +_SOURCE_PRECEDENCE = {"branch": 1, "dashboard": 2, "slash_command": 3} + + +def upsert_session_ticket( + conn: sqlite3.Connection, + *, + session_uuid: str, + session_slug: Optional[str], + ticket_id: int, + link_source: str, +) -> tuple[int, str]: + """Insert-or-find-and-upgrade a session_tickets row. + + Two unique constraints can match an existing row: + 1. UNIQUE(session_uuid, ticket_id) — same session re-linking same ticket. + 2. Partial UNIQUE(session_slug, ticket_id) WHERE slug NOT NULL — + a different UUID resuming the SAME logical session (same slug). + + When either match exists, we reuse that row and possibly upgrade + link_source per precedence (slash_command > dashboard > branch). + Slug is filled in if previously NULL. + + Returns (link_id, effective_link_source). + """ + if link_source not in _SOURCE_PRECEDENCE: + raise ValueError(f"invalid link_source: {link_source!r}") + + existing = _find_existing_link( + conn, + session_uuid=session_uuid, + session_slug=session_slug, + ticket_id=ticket_id, + ) + + if existing is not None: + new_source = ( + link_source + if _SOURCE_PRECEDENCE[link_source] > _SOURCE_PRECEDENCE[existing["link_source"]] + else existing["link_source"] + ) + new_slug = existing["session_slug"] or session_slug + + if new_source != existing["link_source"] or new_slug != existing["session_slug"]: + conn.execute( + """ + UPDATE session_tickets + SET link_source = ?, + session_slug = ? + WHERE id = ? + """, + (new_source, new_slug, existing["id"]), + ) + + return existing["id"], new_source + + # No matching row — insert a fresh one. + inserted = conn.execute( + """ + INSERT INTO session_tickets + (session_uuid, session_slug, ticket_id, link_source, linked_at) + VALUES (?, ?, ?, ?, datetime('now')) + RETURNING id + """, + (session_uuid, session_slug, ticket_id, link_source), + ).fetchone() + return inserted["id"], link_source + + +def _find_existing_link( + conn: sqlite3.Connection, + *, + session_uuid: str, + session_slug: Optional[str], + ticket_id: int, +) -> Optional[dict]: + """Look up an existing session_tickets row matching either unique + constraint. (session_uuid, ticket_id) takes precedence; falls back + to the (session_slug, ticket_id) partial index when slug is present. + """ + row = conn.execute( + """ + SELECT id, session_uuid, session_slug, link_source + FROM session_tickets + WHERE session_uuid = ? AND ticket_id = ? + """, + (session_uuid, ticket_id), + ).fetchone() + if row is not None: + return dict(row) + + if session_slug: + row = conn.execute( + """ + SELECT id, session_uuid, session_slug, link_source + FROM session_tickets + WHERE session_slug = ? AND ticket_id = ? + """, + (session_slug, ticket_id), + ).fetchone() + if row is not None: + return dict(row) + + return None + + +def update_ticket_metadata( + conn: sqlite3.Connection, + *, + provider: str, + external_key: str, + title: Optional[str] = None, + status: Optional[str] = None, + metadata_json: Optional[str] = None, +) -> bool: + """Refresh cached metadata for a ticket. + + `COALESCE` preserves existing non-null values when the caller passes + None — so a degraded slash-command call (MCP unavailable) never wipes + previously cached data. + + Returns True if the row was found, False otherwise. + """ + cur = conn.execute( + """ + UPDATE tickets + SET title = COALESCE(?, title), + status = COALESCE(?, status), + metadata_json = COALESCE(?, metadata_json), + metadata_updated_at = datetime('now') + WHERE provider = ? AND external_key = ? + """, + (title, status, metadata_json, provider, external_key), + ) + return cur.rowcount > 0 + + +def delete_session_ticket( + conn: sqlite3.Connection, + *, + session_uuid: str, + ticket_id: int, +) -> bool: + """Unlink one ticket from one session. Returns True if a row was removed.""" + cur = conn.execute( + "DELETE FROM session_tickets WHERE session_uuid = ? AND ticket_id = ?", + (session_uuid, ticket_id), + ) + return cur.rowcount > 0 + + +def cleanup_orphan_session_tickets( + conn: sqlite3.Connection, + *, + ttl_days: int = 7, +) -> int: + """Delete session_tickets rows whose session_uuid never materialized. + + Run periodically from the FastAPI lifespan task. Returns count removed. + """ + cur = conn.execute( + f""" + DELETE FROM session_tickets + WHERE session_uuid NOT IN (SELECT uuid FROM sessions) + AND linked_at < datetime('now', '-{int(ttl_days)} days') + """, + ) + return cur.rowcount + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + + +def get_ticket_by_key( + conn: sqlite3.Connection, + *, + provider: str, + external_key: str, +) -> Optional[dict]: + """Fetch one ticket row by (provider, external_key).""" + row = conn.execute( + """ + SELECT id, provider, external_key, url, title, status, + metadata_json, metadata_updated_at, first_seen_at + FROM tickets + WHERE provider = ? AND external_key = ? + """, + (provider, external_key), + ).fetchone() + return dict(row) if row else None + + +def get_ticket_by_id(conn: sqlite3.Connection, ticket_id: int) -> Optional[dict]: + """Fetch one ticket row by id.""" + row = conn.execute( + """ + SELECT id, provider, external_key, url, title, status, + metadata_json, metadata_updated_at, first_seen_at + FROM tickets + WHERE id = ? + """, + (ticket_id,), + ).fetchone() + return dict(row) if row else None + + +def get_session_tickets(conn: sqlite3.Connection, session_uuid: str) -> list[dict]: + """All tickets linked to one session, with link metadata inline.""" + rows = conn.execute( + """ + SELECT t.id, t.provider, t.external_key, t.url, t.title, t.status, + t.metadata_json, t.metadata_updated_at, t.first_seen_at, + st.id AS link_id, + st.link_source AS link_source, + st.linked_at AS linked_at, + st.session_slug AS session_slug + FROM session_tickets st + JOIN tickets t ON t.id = st.ticket_id + WHERE st.session_uuid = ? + ORDER BY st.linked_at DESC + """, + (session_uuid,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_ticket_sessions( + conn: sqlite3.Connection, + *, + provider: str, + external_key: str, +) -> list[dict]: + """Sessions linked to one ticket. LEFT JOIN to sessions so orphan + links (session_uuid present in session_tickets but not yet in the + sessions index) still appear with NULL session fields.""" + rows = conn.execute( + """ + SELECT st.id AS link_id, + st.session_uuid AS session_uuid, + st.session_slug AS session_slug, + st.link_source AS link_source, + st.linked_at AS linked_at, + s.slug AS sessions_slug, + s.project_encoded_name AS project_encoded_name, + s.start_time AS start_time, + s.end_time AS end_time, + s.initial_prompt AS initial_prompt + FROM session_tickets st + JOIN tickets t ON t.id = st.ticket_id + LEFT JOIN sessions s ON s.uuid = st.session_uuid + WHERE t.provider = ? AND t.external_key = ? + ORDER BY st.linked_at DESC + """, + (provider, external_key), + ).fetchall() + return [dict(r) for r in rows] + + +def list_tickets( + conn: sqlite3.Connection, + *, + provider: Optional[str] = None, + q: Optional[str] = None, + project: Optional[str] = None, +) -> list[dict]: + """List tickets with session counts. + + Filters: + provider — exact provider match. + q — case-insensitive substring of external_key or title. + project — encoded project name; aggregates tickets across all + encoded_names sharing the target project's `git_identity`, + with two fallbacks. + + `project` resolution path (in priority order): + + A) git_identity match — if the target project has a non-NULL + git_identity, include tickets linked from any session in any + project sharing that git_identity. This is what makes worktrees, + subdir projects, and other local checkouts of the same repo + present a unified ticket view. + + B) GitHub external_key heuristic — for `provider='github'` tickets + only, include tickets whose external_key (lowercased) starts + with `{git_identity}#`, even if no local session has linked + them yet. Catches the cross-machine sync case where the ticket + was linked on a peer's machine. + + Note on `session_count` for path-B-only tickets: the count + reflects total links anywhere in the DB (this ticket's + popularity), not links within this project. A ticket surfaced + only via the heuristic — never linked locally — may show + session_count > 0 if a peer linked it elsewhere. That's the + signal we want; treating those tickets as "0 sessions" would + hide useful information. + + C) Per-encoded fallback — when target git_identity is NULL (e.g. + a sync-imported project with no local checkout, or a project + predating the v12 backfill), revert to the legacy behavior: + match only sessions whose project_encoded_name equals `project`. + + All three paths share one query — no Python post-filtering. Orphan + links (session_uuid not in sessions table) are excluded from path A + naturally because the join to sessions returns NULL p.git_identity; + they are included from path C via the LIKE-the-old-way clause. + """ + where = [] + params: list = [] + if provider: + where.append("t.provider = ?") + params.append(provider) + if q: + where.append("(t.external_key LIKE ? OR LOWER(COALESCE(t.title,'')) LIKE LOWER(?))") + like = f"%{q}%" + params.extend([like, like]) + + extra_join = "" + if project: + # Resolve the target's git_identity once and reuse via parameters. + # SQLite scalar subqueries would also work; binding explicitly + # keeps the query plan stable and makes the fallback semantics + # easier to reason about. + row = conn.execute( + "SELECT git_identity FROM projects WHERE encoded_name = ?", + (project,), + ).fetchone() + target_git_identity = row["git_identity"] if row else None + + # Join sessions + projects so we can match on either path. + extra_join = ( + "LEFT JOIN sessions s ON s.uuid = st.session_uuid " + "LEFT JOIN projects p ON p.encoded_name = s.project_encoded_name" + ) + + if target_git_identity: + # Path A (git_identity match) OR Path B (GitHub key heuristic). + # Path B doesn't require any link to exist locally — that's how + # tickets surface on a sync-imported view of the same repo. + where.append( + "(" + " p.git_identity = ?" + " OR (t.provider = 'github' AND LOWER(t.external_key) LIKE ?)" + ")" + ) + params.append(target_git_identity) + params.append(f"{target_git_identity}#%") + else: + # Path C — legacy per-encoded_name match. Mirrors pre-v12 + # behavior for projects that have no git_identity yet. + where.append("s.project_encoded_name = ?") + params.append(project) + + where_clause = ("WHERE " + " AND ".join(where)) if where else "" + + rows = conn.execute( + f""" + SELECT t.id, t.provider, t.external_key, t.url, t.title, t.status, + t.first_seen_at, t.metadata_updated_at, + COUNT(DISTINCT st.id) AS session_count, + MAX(st.linked_at) AS last_linked_at + FROM tickets t + LEFT JOIN session_tickets st ON st.ticket_id = t.id + {extra_join} + {where_clause} + GROUP BY t.id + ORDER BY COALESCE(MAX(st.linked_at), t.first_seen_at) DESC + """, + params, + ).fetchall() + return [dict(r) for r in rows] + + +def get_link_row( + conn: sqlite3.Connection, + link_id: int, +) -> Optional[dict]: + """Fetch one session_tickets row by id.""" + row = conn.execute( + """ + SELECT id, session_uuid, session_slug, ticket_id, link_source, linked_at + FROM session_tickets + WHERE id = ? + """, + (link_id,), + ).fetchone() + return dict(row) if row else None diff --git a/api/main.py b/api/main.py index 693ff51b..365471dc 100644 --- a/api/main.py +++ b/api/main.py @@ -35,6 +35,7 @@ sessions, skills, subagent_sessions, + tickets, tools, ) from routers import settings as settings_router # noqa: E402 @@ -110,6 +111,15 @@ async def lifespan(app: FastAPI): settings.reconciler_idle_threshold, ) + # Start ticket-orphan cleanup loop (deletes session_tickets rows whose + # session_uuid never materialized in the sessions index after a TTL). + orphan_cleanup_task = None + if settings.use_sqlite: + from services.ticket_cleanup import run_ticket_orphan_cleanup + + orphan_cleanup_task = asyncio.create_task(run_ticket_orphan_cleanup()) + logger.info("Ticket orphan cleanup task started") + yield # Shutdown @@ -121,6 +131,10 @@ async def lifespan(app: FastAPI): periodic_task.cancel() logger.info("Periodic reindex task cancelled") + if orphan_cleanup_task is not None: + orphan_cleanup_task.cancel() + logger.info("Ticket orphan cleanup task cancelled") + if settings.use_sqlite: try: from db.connection import close_db @@ -172,6 +186,7 @@ async def lifespan(app: FastAPI): tags=["subagent-sessions"], ) app.include_router(admin.router) +app.include_router(tickets.router) @app.get("/") diff --git a/api/models/live_session.py b/api/models/live_session.py index 4ea57955..bf7c6067 100644 --- a/api/models/live_session.py +++ b/api/models/live_session.py @@ -363,12 +363,18 @@ def load_live_session_by_slug(slug: str) -> Optional[LiveSessionState]: def load_all_live_sessions() -> List[LiveSessionState]: - """Load all live session states.""" + """Load all live session states. + + Tolerates per-file failures (corruption, partial writes, files + deleted between glob and open) so a single bad file doesn't abort + the whole batch — important for callers like the ticket-session + enrichment service that depend on best-effort coverage. + """ sessions = [] for state_file in list_live_session_files(): try: sessions.append(LiveSessionState.from_file(state_file)) - except (json.JSONDecodeError, ValueError, KeyError) as e: + except (json.JSONDecodeError, ValueError, KeyError, OSError, UnicodeDecodeError) as e: logger.warning(f"Failed to parse live session {state_file.stem}: {e}") continue return sessions diff --git a/api/models/ticket.py b/api/models/ticket.py new file mode 100644 index 00000000..de5f5a48 --- /dev/null +++ b/api/models/ticket.py @@ -0,0 +1,114 @@ +""" +Ticket models for session ↔ ticket linking. + +Karma is a read-only observer. The agent (via MCP) supplies metadata at link +time; karma stores the link record and caches title/status for display. + +See: docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +Provider = Literal["linear", "jira", "github"] +LinkSource = Literal["branch", "slash_command", "dashboard"] + + +class TicketRef(BaseModel): + """Output of the URL/ref parser. Immutable.""" + + model_config = ConfigDict(frozen=True) + + provider: Provider + external_key: str = Field(min_length=1) + url: str = Field(min_length=1) + + +class Ticket(BaseModel): + """A ticket row from the registry.""" + + model_config = ConfigDict(frozen=True) + + id: int + provider: Provider + external_key: str + url: str + title: Optional[str] = None + status: Optional[str] = None + metadata_json: Optional[str] = None + metadata_updated_at: Optional[str] = None + first_seen_at: str + + +class SessionTicketLink(BaseModel): + """A link row from session_tickets.""" + + model_config = ConfigDict(frozen=True) + + id: int + session_uuid: str + session_slug: Optional[str] = None + ticket_id: int + link_source: LinkSource + linked_at: str + + +class LinkCreateRequest(BaseModel): + """Body of POST /sessions/{uuid}/tickets.""" + + model_config = ConfigDict(frozen=True) + + ref: str = Field(min_length=1, description="Ticket key or URL (e.g., LINEAR-123 or full URL)") + provider: Optional[Provider] = Field( + default=None, + description="Hint when ref is a bare alphanumeric key like ABC-123. Required for bare keys.", + ) + url: Optional[str] = Field(default=None, description="Optional override URL") + session_slug: Optional[str] = Field( + default=None, + description="Session slug for dedup across resumes. Populate when known.", + ) + source: LinkSource + + +class MetadataUpdate(BaseModel): + """Body of PUT /tickets/{provider}/{external_key} and PATCH variant.""" + + model_config = ConfigDict(frozen=True) + + title: Optional[str] = None + status: Optional[str] = None + metadata_json: Optional[str] = Field( + default=None, + max_length=65536, + description="Raw MCP payload (capped at 64 KB to match the DB CHECK constraint).", + ) + + +class LinkResponse(BaseModel): + """Response from POST /sessions/{uuid}/tickets — full link + ticket.""" + + model_config = ConfigDict(frozen=True) + + link: SessionTicketLink + ticket: Ticket + + +class TicketListItem(BaseModel): + """Row for GET /tickets — ticket plus session count.""" + + model_config = ConfigDict(frozen=True) + + id: int + provider: Provider + external_key: str + url: str + title: Optional[str] = None + status: Optional[str] = None + first_seen_at: str + metadata_updated_at: Optional[str] = None + session_count: int + last_linked_at: Optional[str] = None diff --git a/api/pyproject.toml b/api/pyproject.toml index 19d0354a..afcacc93 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -7,7 +7,7 @@ include = ["models*"] [project] name = "claude-code-models" -version = "0.1.0" +version = "0.2.0" description = "Pydantic models for parsing Claude Code local storage" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/api/routers/admin.py b/api/routers/admin.py index b534c999..a64dd010 100644 --- a/api/routers/admin.py +++ b/api/routers/admin.py @@ -79,3 +79,48 @@ def vacuum_db(): except Exception as e: logger.error("VACUUM failed: %s", e) return {"status": "error", "detail": str(e)} + + +@router.post("/repair-github-urls") +def repair_github_urls(): + """ + Rewrite stale `/issues/N` URLs on GitHub tickets that are actually PRs. + + Background: in versions prior to v0.2.0, the ticket parser collapsed + every GitHub URL — `/pull/N` included — to `/issues/N` when storing + the canonical URL. The fix in v0.2.0 preserves the kind segment, but + rows written by the old parser still point at `/issues/N` even when + they refer to pull requests. The URL still resolves (GitHub redirects + `/issues/N` to `/pull/N` when N is actually a PR), so links work — + but the frontend's `githubKindFromUrl` helper sees `/issues/` and + suppresses the PR badge. + + Detection: we can identify which `/issues/N` rows are actually PRs by + looking at the cached `status` field. `MERGED` is unique to pull + requests; issues only have `open` or `closed`. PRs that are still + open or closed-unmerged can't be detected from status alone — those + will self-heal the next time the user re-links them (the parser + upserts with the corrected URL). + + Safe and idempotent. Reports the number of rows rewritten. + """ + from db.connection import get_writer_db + + try: + conn = get_writer_db() + cur = conn.execute( + """ + UPDATE tickets + SET url = REPLACE(url, '/issues/', '/pull/') + WHERE provider = 'github' + AND url LIKE '%/issues/%' + AND status = 'MERGED' + """ + ) + conn.commit() + rewritten = cur.rowcount + logger.info("Repaired %d GitHub PR URLs (status=MERGED)", rewritten) + return {"status": "ok", "rewritten": rewritten} + except Exception as e: + logger.error("repair-github-urls failed: %s", e) + return {"status": "error", "detail": str(e)} diff --git a/api/routers/agents.py b/api/routers/agents.py index decae259..dbce68cb 100644 --- a/api/routers/agents.py +++ b/api/routers/agents.py @@ -32,6 +32,7 @@ get_agent_detail, get_agent_history, ) +from routers.projects import safely_resolve_project from schemas import ( AgentCreateRequest, AgentDetail, @@ -73,7 +74,8 @@ def get_settings() -> Settings: def get_agents_dir( config: Annotated[Settings, Depends(get_settings)], project: Annotated[ - str | None, Query(description="Project encoded name for project-specific agents") + str | None, + Query(description="Project identifier (slug or encoded_name) for project-specific agents"), ] = None, ) -> Path: """ @@ -81,13 +83,14 @@ def get_agents_dir( Args: config: Application settings (injected) - project: Optional project encoded name for project-specific agents + project: Optional project identifier (slug or encoded_name) Returns: Path to agents directory (global or project-specific) """ if project: - proj = Project.from_encoded_name(project) + resolved = safely_resolve_project(project) or project + proj = Project.from_encoded_name(resolved) return Path(proj.path) / ".claude" / "agents" return config.agents_dir @@ -326,7 +329,9 @@ def get_agent_usage_trend( with sqlite_read() as conn: if conn is not None: - data = query_agent_usage_trend(conn, project=project, period=period) + data = query_agent_usage_trend( + conn, project=safely_resolve_project(project), period=period + ) return UsageTrendResponse( total=data["total"], by_item=data["by_item"], diff --git a/api/routers/commands.py b/api/routers/commands.py index 49f8119d..84c20299 100644 --- a/api/routers/commands.py +++ b/api/routers/commands.py @@ -22,6 +22,7 @@ from http_caching import cacheable from models import Project from parallel import run_in_thread +from routers.projects import safely_resolve_project from schemas import CommandContent, CommandDetailResponse, CommandInfo, CommandItem logger = logging.getLogger(__name__) @@ -41,13 +42,17 @@ def get_settings() -> Settings: def get_commands_dir( config: Annotated[Settings, Depends(get_settings)], project: Annotated[ - str | None, Query(description="Project encoded name for project-specific commands") + str | None, + Query( + description="Project identifier (slug or encoded_name) for project-specific commands" + ), ] = None, ) -> Path: """Get the commands directory (global or project-specific).""" if project: + resolved = safely_resolve_project(project) or project try: - proj = Project.from_encoded_name(project) + proj = Project.from_encoded_name(resolved) return Path(proj.path) / ".claude" / "commands" except Exception as err: raise HTTPException(status_code=400, detail="Invalid project name") from err @@ -208,7 +213,7 @@ def get_command_usage( if conn is None: return [] - rows = query_command_usage(conn, project=project, limit=100) + rows = query_command_usage(conn, project=safely_resolve_project(project), limit=100) results = [] for row in rows: cmd_name = row["command_name"] @@ -248,7 +253,9 @@ def get_command_usage_trend( try: with sqlite_read() as conn: if conn is not None: - return query_command_usage_trend(conn, project=project, period=period) + return query_command_usage_trend( + conn, project=safely_resolve_project(project), period=period + ) except sqlite3.Error as e: logger.warning("SQLite command trend query failed: %s", e) @@ -317,7 +324,8 @@ async def get_command_detail( cmd_file = None if project: try: - proj = Project.from_encoded_name(project) + resolved = safely_resolve_project(project) or project + proj = Project.from_encoded_name(resolved) project_cmd = Path(proj.path) / ".claude" / "commands" / f"{command_name}.md" if project_cmd.is_file(): cmd_file = project_cmd @@ -424,7 +432,8 @@ async def get_command_info( # 1. Project-level commands if project: try: - proj = Project.from_encoded_name(project) + resolved = safely_resolve_project(project) or project + proj = Project.from_encoded_name(resolved) project_cmd = Path(proj.path) / ".claude" / "commands" / f"{command_name}.md" if project_cmd.is_file(): command_file = project_cmd diff --git a/api/routers/live_sessions.py b/api/routers/live_sessions.py index fe2c7cfe..8f50ee74 100644 --- a/api/routers/live_sessions.py +++ b/api/routers/live_sessions.py @@ -46,6 +46,7 @@ load_live_session, ) from models.project import Project +from routers.projects import safely_resolve_project from schemas import LiveSessionsResponse, LiveSessionSummary logger = logging.getLogger(__name__) @@ -434,6 +435,12 @@ async def list_project_live_sessions( This endpoint includes session stats (message_count, subagent_count, slug) loaded from the session JSONL files for real-time updates on project page. """ + # Accept either slug or encoded_name; resolve to canonical encoded_name + # so live-session filtering matches what the indexer wrote. Without this, + # a URL like /live-sessions/project/claude-karma-1044 (slug form, as + # the frontend sends it) would never match resolved_project_encoded_name. + project_encoded_name = safely_resolve_project(project_encoded_name) or project_encoded_name + states = await load_all_live_sessions_async() # Filter by project using resolved name (handles submodule→parent mapping) diff --git a/api/routers/projects.py b/api/routers/projects.py index b8a4ce0a..00124756 100644 --- a/api/routers/projects.py +++ b/api/routers/projects.py @@ -165,6 +165,28 @@ def resolve_project_identifier(identifier: str) -> str: raise HTTPException(status_code=404, detail=f"Project not found: {identifier}") +def safely_resolve_project(identifier: Optional[str]) -> Optional[str]: + """Filter-param variant of `resolve_project_identifier`. + + Unlike its strict cousin, this function NEVER raises: + - `None` in → `None` out (no filter applied downstream) + - Unknown identifier → returns the input verbatim, letting the + downstream query naturally return an empty list. Filter endpoints + should yield zero results for an unknown project, not 404. + - Known slug or encoded_name → returns canonical encoded_name. + + Use this for any router that accepts `?project=...` as a filter + where the project may legitimately not exist (e.g. a saved query + against a deleted project) and 404 would be the wrong response. + """ + if not identifier: + return None + try: + return resolve_project_identifier(identifier) + except HTTPException: + return identifier + + def _count_worktree_sessions(real_encoded: str) -> int: """Count sessions in worktree dirs mapped to a real project.""" wt_encodeds = get_worktree_mappings_for_project(real_encoded) diff --git a/api/routers/skills.py b/api/routers/skills.py index cc3296bd..8734b7fc 100644 --- a/api/routers/skills.py +++ b/api/routers/skills.py @@ -33,6 +33,7 @@ from http_caching import cacheable from models import Project from parallel import run_in_thread +from routers.projects import safely_resolve_project from schemas import ( SessionSummary, SessionWithContext, @@ -75,7 +76,8 @@ def get_settings() -> Settings: def get_skills_dir( config: Annotated[Settings, Depends(get_settings)], project: Annotated[ - str | None, Query(description="Project encoded name for project-specific skills") + str | None, + Query(description="Project identifier (slug or encoded_name) for project-specific skills"), ] = None, ) -> Path: """ @@ -83,13 +85,14 @@ def get_skills_dir( Args: config: Application settings (injected) - project: Optional project encoded name for project-specific skills + project: Optional project identifier (slug or encoded_name) Returns: Path to skills directory (global or project-specific) """ if project: - proj = Project.from_encoded_name(project) + resolved = safely_resolve_project(project) or project + proj = Project.from_encoded_name(resolved) return Path(proj.path) / ".claude" / "skills" return config.skills_dir @@ -411,7 +414,7 @@ def get_skill_usage( with sqlite_read() as conn: if conn is not None: - rows = query_skill_usage(conn, project=project, limit=limit) + rows = query_skill_usage(conn, project=safely_resolve_project(project), limit=limit) results = [] for row in rows: skill_name = row["skill_name"] @@ -453,8 +456,10 @@ def process_session_skills(session) -> dict[str, int]: # Collect all sessions to process sessions_to_process = [] if project: - # Get skill usage for a specific project - proj = Project.from_encoded_name(project) + # Resolve slug-or-encoded so this fallback path matches what the + # SQLite fast-path above does. Without this, a slug URL would + # crash here exactly like the original /projects→tickets bug. + proj = Project.from_encoded_name(safely_resolve_project(project) or project) sessions_to_process = list(proj.list_sessions()) else: # Get skill usage across all projects @@ -526,7 +531,9 @@ def get_skill_usage_trend( with sqlite_read() as conn: if conn is not None: - data = query_skill_usage_trend(conn, project=project, period=period) + data = query_skill_usage_trend( + conn, project=safely_resolve_project(project), period=period + ) return UsageTrendResponse( total=data["total"], by_item=data["by_item"], diff --git a/api/routers/tickets.py b/api/routers/tickets.py new file mode 100644 index 00000000..6a6103bd --- /dev/null +++ b/api/routers/tickets.py @@ -0,0 +1,275 @@ +""" +Ticket linking router. + +Endpoints: + POST /sessions/{uuid}/tickets create link (idempotent) + GET /sessions/{uuid}/tickets list linked tickets + DELETE /sessions/{uuid}/tickets/{ticket_id} unlink + PUT /tickets/{provider}/{external_key} refresh cached metadata + PATCH /tickets/{provider}/{external_key} dashboard manual metadata edit + GET /tickets list all tickets w/ session_count + GET /tickets/{provider}/{external_key} ticket detail + GET /tickets/{provider}/{external_key}/sessions sessions linked to ticket + +Writes go through the writer singleton (get_writer_db); reads use a +short-lived read connection (create_read_connection). The router is +mounted in main.py with no prefix because it spans two URL roots. + +See: docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Optional + +from fastapi import APIRouter, HTTPException, Query + +from db.connection import create_read_connection, get_writer_db +from db.ticket_queries import ( + delete_session_ticket, + get_link_row, + get_session_tickets, + get_ticket_by_id, + get_ticket_by_key, + get_ticket_sessions, + list_tickets, + update_ticket_metadata, + upsert_session_ticket, + upsert_ticket, +) +from models.ticket import ( + LinkCreateRequest, + LinkResponse, + MetadataUpdate, + Provider, + SessionTicketLink, + Ticket, + TicketListItem, +) +from routers.projects import safely_resolve_project +from services.ticket_parser import parse_ticket_ref +from services.ticket_session_enrichment import enrich_sessions_with_live + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["tickets"]) + + +def _bad_ref(ref: str) -> HTTPException: + return HTTPException( + status_code=400, + detail={ + "error": "could not parse ticket ref", + "ref": ref, + "hint": ( + "Pass a recognized URL (Linear/Jira/GitHub) or a fully-qualified " + "ref like 'LINEAR-123' (with provider='linear') or 'owner/repo#42'. " + "Bare '#N' is not supported — qualify with owner/repo." + ), + }, + ) + + +# --------------------------------------------------------------------------- +# Session-scoped: link/list/unlink +# --------------------------------------------------------------------------- + + +@router.post("/sessions/{uuid}/tickets", response_model=LinkResponse) +def create_link(uuid: str, body: LinkCreateRequest) -> LinkResponse: + """Link a ticket to a session. + + Idempotent on (session_uuid, ticket_id). `link_source` upgrades on + re-POST per precedence (slash_command > dashboard > branch); never + downgrades. Metadata is NOT touched here — use PUT /tickets/... after + an MCP fetch. + """ + ref = parse_ticket_ref(body.ref, hint_provider=body.provider) + if ref is None: + raise _bad_ref(body.ref) + + # Caller-supplied URL wins over the parser's best-effort URL + # (e.g., dashboard pastes a full URL; parser may have generated a + # search-page fallback for a bare key). + canonical_url = body.url or ref.url + + conn = get_writer_db() + try: + conn.execute("BEGIN IMMEDIATE") + + ticket_id = upsert_ticket( + conn, + provider=ref.provider, + external_key=ref.external_key, + url=canonical_url, + ) + + link_id, effective_source = upsert_session_ticket( + conn, + session_uuid=uuid, + session_slug=body.session_slug, + ticket_id=ticket_id, + link_source=body.source, + ) + + conn.commit() + except Exception: + conn.rollback() + raise + + ticket_row = get_ticket_by_id(conn, ticket_id) + link_row = get_link_row(conn, link_id) + if ticket_row is None or link_row is None: + # Defensive — both rows just got created in the same transaction. + raise HTTPException(status_code=500, detail="link created but row not found") + + return LinkResponse( + ticket=Ticket(**ticket_row), + link=SessionTicketLink(**link_row), + ) + + +@router.get("/sessions/{uuid}/tickets") +def list_session_tickets(uuid: str) -> list[dict]: + """All tickets linked to one session, with the link metadata inline.""" + conn = create_read_connection() + try: + return get_session_tickets(conn, uuid) + finally: + conn.close() + + +@router.delete("/sessions/{uuid}/tickets/{ticket_id}") +def unlink_session_ticket(uuid: str, ticket_id: int) -> dict: + """Unlink one ticket from one session. The ticket row stays in the + registry — another session may still be linked.""" + conn = get_writer_db() + try: + removed = delete_session_ticket(conn, session_uuid=uuid, ticket_id=ticket_id) + conn.commit() + except Exception: + conn.rollback() + raise + if not removed: + raise HTTPException(status_code=404, detail="link not found") + return {"deleted": True} + + +# --------------------------------------------------------------------------- +# Ticket-centric: list/detail/refresh +# --------------------------------------------------------------------------- + + +@router.get("/tickets", response_model=list[TicketListItem]) +def list_all_tickets( + provider: Annotated[Optional[Provider], Query()] = None, + q: Annotated[Optional[str], Query(description="Substring of key or title")] = None, + project: Annotated[ + Optional[str], + Query( + description=( + "Project identifier — accepts either the URL slug " + "(e.g. 'myrepo-1044') or the raw encoded_name " + "(e.g. '-Users-me-myrepo'). Restricts to tickets that " + "touch this project, with cross-encoded aggregation when " + "the project has a populated git_identity." + ) + ), + ] = None, +) -> list[TicketListItem]: + """List tickets with session counts. Filterable by provider, project, + and substring search across key/title. + + The `project` param accepts either form (slug or encoded_name) via + `safely_resolve_project`, which is essential because the user-facing + URL carries the slug while internal session APIs use encoded_names. + """ + conn = create_read_connection() + try: + rows = list_tickets( + conn, + provider=provider, + q=q, + project=safely_resolve_project(project), + ) + finally: + conn.close() + return [TicketListItem(**r) for r in rows] + + +# Declare /sessions route BEFORE the bare ticket detail so Starlette's +# non-greedy {:path} match prefers the more specific suffix when present. +@router.get("/tickets/{provider}/{external_key:path}/sessions") +def list_sessions_for_ticket(provider: Provider, external_key: str) -> list[dict]: + """All sessions linked to one ticket. + + Rows for sessions that haven't been indexed yet (active sessions whose + JSONL is still being written) are enriched from the live-sessions + filesystem via `enrich_sessions_with_live`. True orphans (no indexed + row AND no live state) keep NULL session fields and `live: None` so the + frontend can render them distinctly. + """ + conn = create_read_connection() + try: + rows = get_ticket_sessions(conn, provider=provider, external_key=external_key) + finally: + conn.close() + return enrich_sessions_with_live(rows) + + +@router.get("/tickets/{provider}/{external_key:path}", response_model=Ticket) +def get_ticket(provider: Provider, external_key: str) -> Ticket: + """One ticket by (provider, external_key). external_key uses :path so + GitHub-style 'owner/repo#42' (which contains a slash) routes correctly.""" + conn = create_read_connection() + try: + row = get_ticket_by_key(conn, provider=provider, external_key=external_key) + finally: + conn.close() + if row is None: + raise HTTPException(status_code=404, detail="ticket not found") + return Ticket(**row) + + +def _refresh_metadata(provider: Provider, external_key: str, body: MetadataUpdate) -> Ticket: + conn = get_writer_db() + try: + found = update_ticket_metadata( + conn, + provider=provider, + external_key=external_key, + title=body.title, + status=body.status, + metadata_json=body.metadata_json, + ) + conn.commit() + except Exception: + conn.rollback() + raise + if not found: + raise HTTPException( + status_code=404, + detail={ + "error": "ticket not found", + "hint": "create the link first via POST /sessions/{uuid}/tickets", + }, + ) + row = get_ticket_by_key(conn, provider=provider, external_key=external_key) + assert row is not None # just updated successfully + return Ticket(**row) + + +@router.put("/tickets/{provider}/{external_key:path}", response_model=Ticket) +def refresh_ticket_metadata(provider: Provider, external_key: str, body: MetadataUpdate) -> Ticket: + """Agent-driven refresh: replace title/status/metadata with MCP-fetched + values. COALESCE preserves existing non-null fields when caller passes + None, so a degraded MCP fetch never wipes prior data.""" + return _refresh_metadata(provider, external_key, body) + + +@router.patch("/tickets/{provider}/{external_key:path}", response_model=Ticket) +def patch_ticket_metadata(provider: Provider, external_key: str, body: MetadataUpdate) -> Ticket: + """Dashboard manual metadata edit. Same DB semantics as PUT; distinct + endpoint kept for auditability (future: emit different log events).""" + return _refresh_metadata(provider, external_key, body) diff --git a/api/services/git_identity.py b/api/services/git_identity.py new file mode 100644 index 00000000..2cfa024a --- /dev/null +++ b/api/services/git_identity.py @@ -0,0 +1,111 @@ +""" +Git remote → canonical project identity. + +`git_identity` is a machine-independent identity for a git repo, in the +form `owner/repo` (lowercase). It lets us treat all local checkouts, +worktrees, subfolders, and sync-imported variants of one repo as the +same project for cross-cutting views (e.g. "tickets touched in this +project" aggregates across encoded_names sharing a git_identity). + +Two functions: + normalize_git_url(url) — pure parser. URL string → "owner/repo" or None. + read_git_identity(path) — composes the subprocess + the parser. Safe + (timeout + swallowed errors); returns None + when path isn't a git checkout, the remote + isn't set, git is unavailable, etc. + +The parser handles the URL shapes git accepts for `remote.origin.url`: + https://github.com/Owner/Repo.git + https://github.com/Owner/Repo + git@github.com:Owner/Repo.git + ssh://git@github.com/Owner/Repo.git +plus GitLab/Bitbucket variants of the same shapes. Output is always +lowercased so case-only differences in case-insensitive providers +(GitHub) don't fragment the identity. + +We don't include the host (so github.com/foo/bar and gitlab.com/foo/bar +collide). This matches the sync_projects format already in use and is +acceptable for the current scope — see the v12 schema migration notes. +""" + +from __future__ import annotations + +import logging +import re +import subprocess +from typing import Optional + +logger = logging.getLogger(__name__) + +# Match either: +# scp-style: user@host:path (no slashes between host and path) +# url-style: scheme://[user@]host/path +# In both cases we want the path portion (everything after host). +_SCP_RE = re.compile(r"^[^@]+@[^:]+:(?P.+)$") +_URL_RE = re.compile(r"^[a-z]+://(?:[^@/]+@)?[^/]+/(?P.+)$", re.IGNORECASE) + + +def normalize_git_url(url: Optional[str]) -> Optional[str]: + """Parse a git remote URL into canonical `owner/repo` (lowercase). + + Returns None on empty input or anything we can't reduce to at least + `owner/repo` (e.g. a local path, a URL with no path segments). + """ + if not url: + return None + url = url.strip() + if not url: + return None + + # Try scp-style first; it's more restrictive and would otherwise + # be misparsed as a URL-style match would fail anyway. + m = _SCP_RE.match(url) or _URL_RE.match(url) + if m is None: + return None + + path = m.group("path").strip("/") + if path.endswith(".git"): + path = path[:-4] + + # Require at least owner/repo. Reject anything shallower. + parts = [p for p in path.split("/") if p] + if len(parts) < 2: + return None + + # Take owner/repo from the LAST two segments. This handles + # self-hosted setups where the path includes a group hierarchy + # (gitlab.example.com/team/subgroup/owner/repo) — we keep the + # repo's immediate parent as the owner, which matches how users + # typically refer to the repo. + owner, repo = parts[-2], parts[-1] + return f"{owner}/{repo}".lower() + + +def read_git_identity(project_path: Optional[str]) -> Optional[str]: + """Read `remote.origin.url` from the git config at `project_path` + and normalize it. Returns None whenever anything goes wrong — this + function is called from the indexer hot path and must never raise. + + The shellout uses the same defensive pattern as + `hooks/ticket_branch_detector.py:git_current_branch`: 2-second + timeout, `check=False`, swallowed FileNotFoundError/TimeoutExpired/ + OSError. `git config --get` returns exit code 1 (not 0) when the + key is absent, which we treat as "no remote configured" → None. + """ + if not project_path: + return None + try: + result = subprocess.run( + ["git", "-C", project_path, "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e: + logger.debug("git_identity read failed for %s: %r", project_path, e) + return None + + if result.returncode != 0: + return None + return normalize_git_url(result.stdout) diff --git a/api/services/ticket_cleanup.py b/api/services/ticket_cleanup.py new file mode 100644 index 00000000..f927ba3f --- /dev/null +++ b/api/services/ticket_cleanup.py @@ -0,0 +1,70 @@ +""" +Background task that periodically removes orphan session_tickets rows. + +An orphan is a link whose `session_uuid` never appears in the sessions +index — for example, the branch-detect hook fired at SessionStart for a +session that was killed before its JSONL was written. + +Loop interval defaults to 6 hours; TTL before deletion defaults to 7 days +(both match the spec). Uses the same FastAPI lifespan + asyncio.create_task +pattern as session_reconciler. +""" + +from __future__ import annotations + +import asyncio +import logging + +logger = logging.getLogger(__name__) + +_DEFAULT_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours +_DEFAULT_TTL_DAYS = 7 + + +async def run_ticket_orphan_cleanup( + interval_seconds: int = _DEFAULT_INTERVAL_SECONDS, + ttl_days: int = _DEFAULT_TTL_DAYS, +) -> None: + """Long-running coroutine: sleep, sweep, repeat. + + Cancellable via task.cancel() from the lifespan shutdown hook. + """ + logger.info( + "Ticket orphan cleanup loop starting (interval=%ds, ttl=%d days)", + interval_seconds, + ttl_days, + ) + while True: + try: + await asyncio.sleep(interval_seconds) + except asyncio.CancelledError: + logger.info("Ticket orphan cleanup loop cancelled") + raise + + try: + await asyncio.to_thread(_run_one_sweep, ttl_days) + except Exception as e: # never let the loop die + logger.warning("Ticket orphan cleanup sweep failed: %s", e) + + +def _run_one_sweep(ttl_days: int) -> None: + """Sync helper executed in a worker thread to avoid blocking the loop. + + Imported lazily so module-level import order doesn't drag the DB + into hook-side use of this file (none today, but defensive). + """ + from db.connection import get_writer_db + from db.ticket_queries import cleanup_orphan_session_tickets + + conn = get_writer_db() + try: + removed = cleanup_orphan_session_tickets(conn, ttl_days=ttl_days) + conn.commit() + except Exception: + conn.rollback() + raise + + if removed: + logger.info("Ticket orphan cleanup removed %d row(s)", removed) + else: + logger.debug("Ticket orphan cleanup: nothing to remove") diff --git a/api/services/ticket_parser.py b/api/services/ticket_parser.py new file mode 100644 index 00000000..7b930451 --- /dev/null +++ b/api/services/ticket_parser.py @@ -0,0 +1,133 @@ +""" +Pure URL/ref parser for ticket links. + +No I/O, no git shell-outs. Resolving things like a bare `#N` to +`owner/repo#N` must be done by the caller (slash command from shell, hook +from git, dashboard via input form). The API server has no notion of +caller cwd, so this module never reaches outside its arguments. + +See: docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md +""" + +from __future__ import annotations + +import re +from typing import Optional + +from models.ticket import Provider, TicketRef + +# Linear: https://linear.app//issue/[/...] +_LINEAR_URL = re.compile( + r"^https?://linear\.app/[^/]+/issue/(?P[A-Z][A-Z0-9_]+-\d+)(?:[/?#].*)?$", + re.IGNORECASE, +) + +# Jira Cloud: https://.atlassian.net/browse/ +_JIRA_URL = re.compile( + r"^https?://[^/]+\.atlassian\.net/browse/(?P[A-Z][A-Z0-9_]+-\d+)(?:[/?#].*)?$", + re.IGNORECASE, +) + +# GitHub: https://github.com///issues/ (or /pull/). +# +# Issues and PRs share a numbering namespace but have distinct URL paths +# and distinct semantics — PRs carry draft/merge state that issues don't. +# We capture the path segment so the canonical URL preserves it; collapsing +# both to /issues/ would silently drop information the UI needs to render +# the right indicator (and would mislead users into thinking their PR is +# an issue). +_GITHUB_URL = re.compile( + r"^https?://github\.com/(?P[^/\s]+)/(?P[^/\s]+)/(?Pissues|pull)/(?P\d+)(?:[/?#].*)?$", + re.IGNORECASE, +) + +# GitHub short ref: owner/repo#N +_GITHUB_SHORT = re.compile(r"^(?P[\w.-]+)/(?P[\w.-]+)#(?P\d+)$") + +# Bare alphanumeric key: ABC-123 (Linear or Jira; ambiguous without hint). +# Case-insensitive to match URL parsing; we normalize to upper at output. +_BARE_KEY = re.compile(r"^(?P[A-Z][A-Z0-9_]+-\d+)$", re.IGNORECASE) + + +def parse_ticket_ref(s: str, hint_provider: Optional[Provider] = None) -> Optional[TicketRef]: + """Parse a ticket reference into provider + external_key + canonical URL. + + Recognized inputs: + - Linear URL https://linear.app//issue/ABC-123 + - Jira URL https://*.atlassian.net/browse/ABC-123 + - GitHub URL https://github.com///issues/N (or /pull/N) + - GitHub short owner/repo#N + - Bare key ABC-123 (requires hint_provider='linear' or 'jira') + + Returns None for unrecognized input. A bare `#N` (no owner/repo) is + explicitly unsupported: callers must qualify GitHub refs themselves + because the API server cannot read the caller's git remote. + """ + if not s: + return None + s = s.strip() + if not s: + return None + + m = _LINEAR_URL.match(s) + if m: + key = m.group("key").upper() + return TicketRef(provider="linear", external_key=key, url=s) + + m = _JIRA_URL.match(s) + if m: + key = m.group("key").upper() + return TicketRef(provider="jira", external_key=key, url=s) + + m = _GITHUB_URL.match(s) + if m: + owner = m.group("owner") + repo = m.group("repo") + num = m.group("num") + kind = m.group("kind").lower() # 'issues' or 'pull' + key = f"{owner}/{repo}#{num}" + # Preserve the user's intent: /pull/ stays /pull/, /issues/ stays + # /issues/. We strip noise (query, fragment, extra path segments) + # but never rewrite the kind segment. + canonical = f"https://github.com/{owner}/{repo}/{kind}/{num}" + return TicketRef(provider="github", external_key=key, url=canonical) + + m = _GITHUB_SHORT.match(s) + if m: + owner = m.group("owner") + repo = m.group("repo") + num = m.group("num") + key = f"{owner}/{repo}#{num}" + # Bare `owner/repo#N` doesn't tell us which kind. Default to + # /issues/ — GitHub auto-redirects /issues/N to /pull/N when N + # is a PR, so the link still resolves. The frontend can refine + # the displayed kind from MCP-fetched metadata if needed. + canonical = f"https://github.com/{owner}/{repo}/issues/{num}" + return TicketRef(provider="github", external_key=key, url=canonical) + + m = _BARE_KEY.match(s) + if m: + if hint_provider not in ("linear", "jira"): + # Ambiguous; caller must provide hint_provider for bare keys. + return None + key = m.group("key").upper() + url = _build_url_for_bare(hint_provider, key) + return TicketRef(provider=hint_provider, external_key=key, url=url) + + return None + + +def _build_url_for_bare(provider: Provider, key: str) -> str: + """Best-effort URL when only a bare key + hint is given. + + We don't know the team/host, so use a search URL or a placeholder + that's still clickable and useful. The slash command and dashboard + will usually supply the full URL via LinkCreateRequest.url, which + takes precedence at the router layer. + """ + if provider == "linear": + return f"https://linear.app/search?q={key}" + if provider == "jira": + return f"https://atlassian.net/browse/{key}" + # github bare keys aren't reachable here (no owner/repo) + return key diff --git a/api/services/ticket_session_enrichment.py b/api/services/ticket_session_enrichment.py new file mode 100644 index 00000000..f3a785b9 --- /dev/null +++ b/api/services/ticket_session_enrichment.py @@ -0,0 +1,174 @@ +""" +Enrich session_tickets rows with live-session fallback data. + +Problem +------- +A session UUID linked to a ticket may not yet have a row in the `sessions` +SQLite table — sessions are indexed at `SessionEnd`, but the link could +have been created mid-session (via the link-ticket-to-session skill, or +the branch-detect hook at SessionStart). The naive LEFT JOIN in +get_ticket_sessions therefore returns NULL session fields for active +sessions, making them indistinguishable from real orphans. + +We have authoritative data for active sessions: the live-session JSON +files written by `hooks/live_session_tracker.py` under +`~/.claude_karma/live-sessions/`. The `LiveSessionState` model exposes +`session_id`, `session_ids[]` (for resumed sessions), `slug`, `state`, +`cwd`, `started_at`, `updated_at`, and a computed +`resolved_project_encoded_name`. + +Strategy +-------- +- Batch-load every live-session file ONCE per request via + `load_all_live_sessions()`. +- Build a `{session_uuid: LiveSessionState}` lookup including both the + current `session_id` and every historical UUID in `session_ids[]`, so + resumed sessions (link made to an earlier UUID, current state under a + newer UUID) still resolve. +- For each session_tickets row whose `sessions_slug` is None (no indexed + sessions row), look up the live state. If found, fill in + `sessions_slug`, `project_encoded_name`, `start_time` from the live + data, and attach a `live` block exposing the active status. +- Rows that have neither a sessions row nor a live state are TRUE + orphans (frontend renders them as such). + +The `live` block is additive — rows with an indexed sessions row are +returned unchanged with `live: None`. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from models.live_session import LiveSessionState, load_all_live_sessions + +logger = logging.getLogger(__name__) + + +def _build_uuid_index(sessions: list[LiveSessionState]) -> dict[str, LiveSessionState]: + """Map every UUID a live state knows about to that state. + + A `LiveSessionState` tracks both `session_id` (current) and + `session_ids[]` (historical, for resumed sessions). A ticket linked + to an earlier UUID must still resolve to the active state. + + Two-pass build to guarantee "current beats historical" regardless of + input order: pass 1 fills from historical session_ids; pass 2 + overwrites with current session_id. Without the explicit two passes, + correctness relied on within-iteration ordering of statements — a + fragile invariant flagged in review. + """ + index: dict[str, LiveSessionState] = {} + # Pass 1: historical UUIDs (lower priority). setdefault keeps the + # first historical reference if two states list the same prior UUID. + for state in sessions: + for prior in state.session_ids or []: + index.setdefault(prior, state) + # Pass 2: current session_id always wins, even if it appeared as a + # historical UUID under a different state in pass 1. + for state in sessions: + if state.session_id: + index[state.session_id] = state + return index + + +def _live_block(state: LiveSessionState) -> dict[str, Any]: + """The additive `live` field returned to the frontend. + + Kept narrow on purpose: only fields the UI actually renders. + `cwd` is included so an unindexed live session can still link out + to its project. + """ + return { + "status": state.state.value if hasattr(state.state, "value") else str(state.state), + "started_at": state.started_at.isoformat() if state.started_at else None, + "last_updated": state.updated_at.isoformat() if state.updated_at else None, + "cwd": state.cwd or None, + } + + +def _augment_row(row: dict[str, Any], state: LiveSessionState) -> None: + """Fill missing sessions-table fields from live state, in place. + + Only fields that the LEFT JOIN would have populated are touched, and + only when they are currently None — never overwrite indexed data. + `initial_prompt` is intentionally not derived (would require reading + an open transcript JSONL). Returns nothing — mutation is the point. + """ + if not row.get("sessions_slug") and state.slug: + row["sessions_slug"] = state.slug + if not row.get("project_encoded_name"): + # `resolved_project_encoded_name` handles worktree / git-root fallback. + encoded = state.resolved_project_encoded_name + if encoded: + row["project_encoded_name"] = encoded + if not row.get("start_time") and state.started_at: + row["start_time"] = state.started_at.isoformat() + + row["live"] = _live_block(state) + + +def enrich_sessions_with_live(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Fall back to live-sessions data for rows missing an indexed session. + + Mutates each row dict in place: every row gets a `live` key (None + when no live state matches), and rows that lacked sessions-table + data get those fields backfilled from live state. Returns the same + list for caller ergonomics. The mutation is intentional — rows are + locally owned by `list_sessions_for_ticket` and not shared. + + Performance note: makes ONE filesystem scan of the live-sessions + directory per call, regardless of how many rows are passed. Suitable + for endpoints that already perform per-request work. + """ + # Fast path: no rows that need enrichment. + missing_indices = [i for i, r in enumerate(rows) if not r.get("sessions_slug")] + if not missing_indices: + # Still attach `live: None` for response-shape consistency. + for r in rows: + r.setdefault("live", None) + return rows + + try: + live_states = load_all_live_sessions() + except Exception as e: + # Live-session read should never break the ticket endpoint. + logger.warning("Live-sessions read failed during ticket enrichment: %s", e) + for r in rows: + r.setdefault("live", None) + return rows + + index = _build_uuid_index(live_states) + + for i in missing_indices: + row = rows[i] + uuid = row.get("session_uuid") + if not uuid: + continue + state = index.get(uuid) + if state is None: + continue # true orphan — leave row as-is + _augment_row(row, state) + + # Rows that already had indexed data get `live: None`. + for r in rows: + r.setdefault("live", None) + + return rows + + +def _find_live_for_uuid(uuid: str) -> Optional[LiveSessionState]: + """Single-row variant — internal convenience for tests only. + + Endpoint code should call `enrich_sessions_with_live` so the + directory scan amortizes across all rows. The leading underscore + is the discouragement: don't reach for this in router code. + """ + if not uuid: + return None + try: + sessions = load_all_live_sessions() + except Exception: + return None + return _build_uuid_index(sessions).get(uuid) diff --git a/api/tests/api/test_admin_repair.py b/api/tests/api/test_admin_repair.py new file mode 100644 index 00000000..a53c6983 --- /dev/null +++ b/api/tests/api/test_admin_repair.py @@ -0,0 +1,148 @@ +""" +Tests for the admin `repair-github-urls` endpoint. + +This endpoint repairs the v0.1.x parser bug that stored every GitHub +ticket URL as `/issues/N` even when the ticket was a pull request. The +repair targets the unambiguous case: a github ticket with status='MERGED' +(unique to PRs) and a URL still pointing at `/issues/`. Open or +closed-unmerged PRs are NOT auto-detectable from status and will +self-heal when re-linked. +""" + +import sys +from pathlib import Path + +import pytest + +_tests_dir = Path(__file__).resolve().parent.parent +_api_dir = _tests_dir.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path, monkeypatch): + db_path = tmp_path / "test_karma.db" + + import db.connection as connection + + monkeypatch.setattr(connection, "get_db_path", lambda: db_path) + connection._writer = None # type: ignore[attr-defined] + + from db.connection import get_writer_db + + get_writer_db() + + from routers import admin + + app = FastAPI() + app.include_router(admin.router) + + yield TestClient(app) + + connection._writer = None # type: ignore[attr-defined] + + +def _insert_ticket(conn, *, provider, external_key, url, status): + conn.execute( + "INSERT INTO tickets (provider, external_key, url, status) VALUES (?, ?, ?, ?)", + (provider, external_key, url, status), + ) + conn.commit() + + +def test_repair_rewrites_merged_pr_with_issues_url(client): + """The diagnostic case: status=MERGED proves it's a PR. Rewrite.""" + import db.connection as connection + + conn = connection.get_writer_db() + _insert_ticket( + conn, + provider="github", + external_key="octocat/repo#36", + url="https://github.com/octocat/repo/issues/36", + status="MERGED", + ) + + r = client.post("/admin/repair-github-urls") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["rewritten"] == 1 + + new_url = conn.execute( + "SELECT url FROM tickets WHERE external_key = ?", + ("octocat/repo#36",), + ).fetchone()["url"] + assert new_url == "https://github.com/octocat/repo/pull/36" + + +def test_repair_leaves_unambiguous_issues_alone(client): + """A closed/open issue (not status=MERGED) is not repaired — it + truly is an issue, or we can't tell. Either way: don't touch.""" + import db.connection as connection + + conn = connection.get_writer_db() + _insert_ticket( + conn, + provider="github", + external_key="octocat/repo#10", + url="https://github.com/octocat/repo/issues/10", + status="open", + ) + _insert_ticket( + conn, + provider="github", + external_key="octocat/repo#11", + url="https://github.com/octocat/repo/issues/11", + status="closed", + ) + + r = client.post("/admin/repair-github-urls") + assert r.json()["rewritten"] == 0 + + rows = conn.execute("SELECT external_key, url FROM tickets ORDER BY external_key").fetchall() + assert all("/issues/" in r["url"] for r in rows) + + +def test_repair_is_idempotent(client): + """Run twice; second run finds nothing to repair (URLs already + rewritten to /pull/) and reports 0.""" + import db.connection as connection + + conn = connection.get_writer_db() + _insert_ticket( + conn, + provider="github", + external_key="octocat/repo#36", + url="https://github.com/octocat/repo/issues/36", + status="MERGED", + ) + + assert client.post("/admin/repair-github-urls").json()["rewritten"] == 1 + assert client.post("/admin/repair-github-urls").json()["rewritten"] == 0 + + +def test_repair_does_not_touch_linear_or_jira(client): + """The endpoint is github-specific. Linear/Jira rows with /issues/ + paths in their URLs (which doesn't happen in practice but could) are + left alone.""" + import db.connection as connection + + conn = connection.get_writer_db() + _insert_ticket( + conn, + provider="linear", + external_key="ABC-1", + # Pathological: a Linear URL that happens to contain /issues/. + # The repair query is restricted to provider='github' so this + # row should be untouched. + url="https://linear.app/team/issue/ABC-1", + status="Done", + ) + + r = client.post("/admin/repair-github-urls") + assert r.json()["rewritten"] == 0 diff --git a/api/tests/api/test_project_identity_helpers.py b/api/tests/api/test_project_identity_helpers.py new file mode 100644 index 00000000..24d99c9a --- /dev/null +++ b/api/tests/api/test_project_identity_helpers.py @@ -0,0 +1,141 @@ +""" +Unit tests for the slug↔encoded_name resolution helpers in +`api/routers/projects.py`. + +Two helpers under test: + resolve_project_identifier(id) — strict: raises 404 on unknown. + safely_resolve_project(id) — filter-friendly: returns input + verbatim on unknown, None on None. + +The strict variant is exercised end-to-end via every endpoint that +takes a project from the URL path; the filter variant has subtle +"empty filter" semantics that deserve direct coverage. +""" + +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + + +@pytest.fixture +def memory_db(monkeypatch): + """Patch the writer + read connections to a single shared in-memory + SQLite. `safely_resolve_project` uses `sqlite_read()` internally, so + we need both connection paths to point at the same DB to seed test + rows.""" + import db.connection as connection + from db.schema import ensure_schema + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + ensure_schema(conn) + + # sqlite_read() opens its own connection via get_db_path; route both + # the writer and the path resolver at the in-memory DB. The simplest + # way: patch sqlite_read to yield our conn directly. + from contextlib import contextmanager + + @contextmanager + def fake_read(): + yield conn + + monkeypatch.setattr(connection, "sqlite_read", fake_read) + yield conn + conn.close() + + +def _seed_project(conn, *, slug: str, encoded_name: str) -> None: + conn.execute( + "INSERT INTO projects (encoded_name, slug, display_name) VALUES (?, ?, ?)", + (encoded_name, slug, encoded_name), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# safely_resolve_project — filter-friendly variant +# --------------------------------------------------------------------------- + + +def test_safely_resolve_project_none_returns_none(): + """`None` in → `None` out. Means "no filter applied" downstream.""" + from routers.projects import safely_resolve_project + + assert safely_resolve_project(None) is None + + +def test_safely_resolve_project_empty_string_returns_none(): + """Empty string also short-circuits to None (no filter).""" + from routers.projects import safely_resolve_project + + assert safely_resolve_project("") is None + + +def test_safely_resolve_project_encoded_name_returns_input(memory_db): + """encoded_name (starts with '-') is recognized and returned verbatim + by `resolve_project_identifier`; our wrapper preserves that.""" + from routers.projects import safely_resolve_project + + # Mock is_worktree_project to return False (avoid filesystem checks) + with patch("services.desktop_sessions.is_worktree_project", return_value=False): + assert safely_resolve_project("-Users-me-myrepo") == "-Users-me-myrepo" + + +def test_safely_resolve_project_known_slug_returns_encoded_name(memory_db): + """Known slug → resolved encoded_name. This is the bug-fix happy path: + the frontend sends a slug, the API resolves to encoded_name internally.""" + from routers.projects import safely_resolve_project + + _seed_project(memory_db, slug="myrepo-1044", encoded_name="-Users-me-myrepo") + + with patch("services.desktop_sessions.is_worktree_project", return_value=False): + assert safely_resolve_project("myrepo-1044") == "-Users-me-myrepo" + + +def test_safely_resolve_project_unknown_identifier_returns_input(memory_db): + """Unknown identifier (not a slug, not an encoded_name) → returned + verbatim so downstream queries get a clean SQL miss rather than 404. + This is the load-bearing semantic — filter endpoints expect empty + results for unknown filters, never exceptions.""" + from routers.projects import safely_resolve_project + + with patch("services.desktop_sessions.is_worktree_project", return_value=False): + # No row seeded, no fallback match — the strict helper would 404. + assert safely_resolve_project("does-not-exist") == "does-not-exist" + + +# --------------------------------------------------------------------------- +# resolve_project_identifier — strict variant (sanity check) +# --------------------------------------------------------------------------- + + +def test_resolve_project_identifier_raises_on_unknown(memory_db): + """Strict variant must raise 404 on unknown — that's the contract + that `safely_resolve_project` deliberately inverts.""" + from routers.projects import resolve_project_identifier + + with patch("services.desktop_sessions.is_worktree_project", return_value=False): + with pytest.raises(HTTPException) as exc: + resolve_project_identifier("does-not-exist") + assert exc.value.status_code == 404 + + +def test_resolve_project_identifier_returns_encoded_for_known_slug(memory_db): + """Sanity: the strict variant still resolves slugs correctly.""" + from routers.projects import resolve_project_identifier + + _seed_project(memory_db, slug="myrepo-1044", encoded_name="-Users-me-myrepo") + + with patch("services.desktop_sessions.is_worktree_project", return_value=False): + assert resolve_project_identifier("myrepo-1044") == "-Users-me-myrepo" diff --git a/api/tests/api/test_tickets.py b/api/tests/api/test_tickets.py new file mode 100644 index 00000000..3da81e9e --- /dev/null +++ b/api/tests/api/test_tickets.py @@ -0,0 +1,607 @@ +""" +Endpoint tests for api/routers/tickets.py. + +Drives the real router via FastAPI's TestClient against a temp SQLite DB, +exercising the full create-link / refresh-metadata / list / unlink loop. +""" + +import sys +from pathlib import Path + +import pytest + +_tests_dir = Path(__file__).resolve().parent.parent +_api_dir = _tests_dir.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) +if str(_tests_dir) not in sys.path: + sys.path.insert(0, str(_tests_dir)) + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Spin up a FastAPI app with the tickets router and a fresh DB. + + sqlite_db_path is a computed @property (not env-driven) so we monkey- + patch the connection module's get_db_path to point at the temp file. + Writer singleton is reset so the next get_writer_db() call opens + against the temp path and runs ensure_schema(). + """ + db_path = tmp_path / "test_karma.db" + + import db.connection as connection + + monkeypatch.setattr(connection, "get_db_path", lambda: db_path) + connection._writer = None # type: ignore[attr-defined] + + # Force schema creation against the temp DB. + from db.connection import get_writer_db + + get_writer_db() + + # The indexer's is_db_ready check is what sqlite_read uses — but our + # router uses create_read_connection() directly, so the ready check + # doesn't gate us. Still, patch it so any sqlite_read() callers work. + import db.indexer as indexer + + monkeypatch.setattr(indexer, "is_db_ready", lambda: True) + + from routers import tickets + + app = FastAPI() + app.include_router(tickets.router) + + yield TestClient(app) + + # Clean up writer so the next test gets a fresh singleton. + connection._writer = None # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# POST /sessions/{uuid}/tickets +# --------------------------------------------------------------------------- + + +def test_create_link_from_full_linear_url(client): + r = client.post( + "/sessions/sess-1/tickets", + json={ + "ref": "https://linear.app/acme/issue/ABC-123", + "source": "slash_command", + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ticket"]["provider"] == "linear" + assert body["ticket"]["external_key"] == "ABC-123" + assert body["link"]["session_uuid"] == "sess-1" + assert body["link"]["link_source"] == "slash_command" + + +def test_create_link_from_bare_key_requires_provider_hint(client): + # Missing provider for an ambiguous bare key + r = client.post( + "/sessions/sess-1/tickets", + json={"ref": "ABC-123", "source": "dashboard"}, + ) + assert r.status_code == 400 + + # With provider hint, succeeds + r = client.post( + "/sessions/sess-1/tickets", + json={"ref": "ABC-123", "provider": "linear", "source": "dashboard"}, + ) + assert r.status_code == 200, r.text + + +def test_create_link_rejects_bare_hash_n(client): + r = client.post( + "/sessions/sess-1/tickets", + json={"ref": "#42", "provider": "github", "source": "dashboard"}, + ) + assert r.status_code == 400 + assert "owner/repo" in r.json()["detail"]["hint"] + + +def test_create_link_idempotent_same_session_same_ticket(client): + body = {"ref": "https://linear.app/acme/issue/ABC-1", "source": "branch"} + r1 = client.post("/sessions/sess-1/tickets", json=body) + r2 = client.post("/sessions/sess-1/tickets", json=body) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["link"]["id"] == r2.json()["link"]["id"] + + +def test_link_source_upgrades_branch_to_slash_command(client): + body_b = {"ref": "https://linear.app/acme/issue/ABC-9", "source": "branch"} + body_s = {"ref": "https://linear.app/acme/issue/ABC-9", "source": "slash_command"} + r1 = client.post("/sessions/sess-1/tickets", json=body_b) + r2 = client.post("/sessions/sess-1/tickets", json=body_s) + assert r1.json()["link"]["link_source"] == "branch" + assert r2.json()["link"]["link_source"] == "slash_command" + + +def test_link_source_does_not_downgrade(client): + body_s = {"ref": "https://linear.app/acme/issue/ABC-10", "source": "slash_command"} + body_b = {"ref": "https://linear.app/acme/issue/ABC-10", "source": "branch"} + client.post("/sessions/sess-1/tickets", json=body_s) + r = client.post("/sessions/sess-1/tickets", json=body_b) + assert r.json()["link"]["link_source"] == "slash_command" + + +def test_session_slug_dedupes_resumed_sessions(client): + """Two resumed sessions of the same slug linking to the same ticket + collapse onto one row via the partial unique index. The second POST + returns the EXISTING link, not an error — that's the whole point of + the slug-based dedup.""" + body_a = { + "ref": "https://linear.app/acme/issue/ABC-11", + "source": "branch", + "session_slug": "happy-slug", + } + body_b = { + "ref": "https://linear.app/acme/issue/ABC-11", + "source": "slash_command", # upgrade source on second POST + "session_slug": "happy-slug", + } + r1 = client.post("/sessions/uuid-a/tickets", json=body_a) + assert r1.status_code == 200 + first_link_id = r1.json()["link"]["id"] + + # Second resume — new uuid, same slug. Should hit existing row. + r2 = client.post("/sessions/uuid-b/tickets", json=body_b) + assert r2.status_code == 200, r2.text + assert r2.json()["link"]["id"] == first_link_id # same row reused + assert r2.json()["link"]["link_source"] == "slash_command" # upgraded + # session_uuid stays as the ORIGINAL uuid since we deduped on slug. + assert r2.json()["link"]["session_uuid"] == "uuid-a" + + +def test_caller_url_overrides_parser_url(client): + custom_url = "https://linear.app/acme/issue/ABC-15/custom-title" + r = client.post( + "/sessions/sess-1/tickets", + json={ + "ref": "ABC-15", + "provider": "linear", + "url": custom_url, + "source": "dashboard", + }, + ) + assert r.status_code == 200 + assert r.json()["ticket"]["url"] == custom_url + + +# --------------------------------------------------------------------------- +# GET /sessions/{uuid}/tickets and DELETE +# --------------------------------------------------------------------------- + + +def test_list_session_tickets_returns_link_metadata_inline(client): + client.post( + "/sessions/sess-2/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-20", "source": "branch"}, + ) + r = client.get("/sessions/sess-2/tickets") + assert r.status_code == 200 + rows = r.json() + assert len(rows) == 1 + assert rows[0]["external_key"] == "ABC-20" + assert rows[0]["link_source"] == "branch" + + +def test_delete_session_ticket_returns_404_when_missing(client): + r = client.delete("/sessions/sess-99/tickets/9999") + assert r.status_code == 404 + + +def test_delete_session_ticket_succeeds_then_404(client): + create = client.post( + "/sessions/sess-3/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-30", "source": "branch"}, + ) + ticket_id = create.json()["ticket"]["id"] + + r1 = client.delete(f"/sessions/sess-3/tickets/{ticket_id}") + assert r1.status_code == 200 + + r2 = client.delete(f"/sessions/sess-3/tickets/{ticket_id}") + assert r2.status_code == 404 + + +# --------------------------------------------------------------------------- +# PUT /tickets/{provider}/{external_key} +# --------------------------------------------------------------------------- + + +def test_put_metadata_refreshes_title_and_status(client): + client.post( + "/sessions/sess-4/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-40", "source": "slash_command"}, + ) + r = client.put( + "/tickets/linear/ABC-40", + json={"title": "Fix login bug", "status": "In Progress"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["title"] == "Fix login bug" + assert body["status"] == "In Progress" + assert body["metadata_updated_at"] + + +def test_put_metadata_preserves_existing_when_null_passed(client): + client.post( + "/sessions/sess-4/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-41", "source": "slash_command"}, + ) + client.put( + "/tickets/linear/ABC-41", + json={"title": "Original", "status": "Open"}, + ) + # Degraded fetch: only status arrives, title is null. Title must survive. + r = client.put( + "/tickets/linear/ABC-41", + json={"status": "Closed"}, + ) + assert r.json()["title"] == "Original" + assert r.json()["status"] == "Closed" + + +def test_put_returns_404_when_ticket_not_yet_linked(client): + r = client.put( + "/tickets/linear/NEVER-1", + json={"title": "x"}, + ) + assert r.status_code == 404 + assert "hint" in r.json()["detail"] + + +def test_put_metadata_over_size_cap_rejected(client): + client.post( + "/sessions/sess-4/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-42", "source": "slash_command"}, + ) + big = "x" * (64 * 1024 + 1) + r = client.put( + "/tickets/linear/ABC-42", + json={"metadata_json": big}, + ) + # Pydantic max_length=65536 trips at validation time → 422. + assert r.status_code in (400, 422, 500) + + +def test_patch_metadata_works_like_put(client): + client.post( + "/sessions/sess-4/tickets", + json={"ref": "https://linear.app/acme/issue/ABC-43", "source": "dashboard"}, + ) + r = client.patch("/tickets/linear/ABC-43", json={"title": "Manually entered"}) + assert r.status_code == 200 + assert r.json()["title"] == "Manually entered" + + +# --------------------------------------------------------------------------- +# GET /tickets and detail +# --------------------------------------------------------------------------- + + +def test_list_tickets_shows_session_count(client): + client.post( + "/sessions/sess-A/tickets", + json={"ref": "https://linear.app/acme/issue/CNT-1", "source": "branch"}, + ) + client.post( + "/sessions/sess-B/tickets", + json={"ref": "https://linear.app/acme/issue/CNT-1", "source": "branch"}, + ) + r = client.get("/tickets") + assert r.status_code == 200, r.text + rows = r.json() + cnt_row = next((row for row in rows if row["external_key"] == "CNT-1"), None) + assert cnt_row is not None + assert cnt_row["session_count"] == 2 + + +def test_list_tickets_filter_by_provider(client): + client.post( + "/sessions/sess-X/tickets", + json={"ref": "https://linear.app/acme/issue/FILT-1", "source": "branch"}, + ) + client.post( + "/sessions/sess-X/tickets", + json={"ref": "octocat/repo#9", "source": "branch"}, + ) + r = client.get("/tickets?provider=github") + assert r.status_code == 200 + rows = r.json() + assert all(row["provider"] == "github" for row in rows) + assert any(row["external_key"] == "octocat/repo#9" for row in rows) + + +def test_list_tickets_search_by_key(client): + client.post( + "/sessions/sess-X/tickets", + json={"ref": "https://linear.app/acme/issue/SRCH-77", "source": "branch"}, + ) + r = client.get("/tickets?q=SRCH") + assert r.status_code == 200 + rows = r.json() + assert any(row["external_key"] == "SRCH-77" for row in rows) + + +def test_list_tickets_project_filter_requires_session_row(client): + """Project filter joins via sessions table; orphan links (no sessions + row yet) won't appear when filtered. We need a real session row for + this test — insert one directly via the writer connection.""" + import db.connection as connection + + conn = connection.get_writer_db() + conn.execute( + "INSERT INTO sessions (uuid, project_encoded_name, jsonl_mtime) VALUES (?, ?, ?)", + ("real-uuid-1", "-Users-me-projA", 0.0), + ) + conn.execute( + "INSERT INTO sessions (uuid, project_encoded_name, jsonl_mtime) VALUES (?, ?, ?)", + ("real-uuid-2", "-Users-me-projB", 0.0), + ) + conn.commit() + + # Link two tickets to projA's session, one to projB's + client.post( + "/sessions/real-uuid-1/tickets", + json={"ref": "https://linear.app/acme/issue/PA-1", "source": "branch"}, + ) + client.post( + "/sessions/real-uuid-1/tickets", + json={"ref": "https://linear.app/acme/issue/PA-2", "source": "branch"}, + ) + client.post( + "/sessions/real-uuid-2/tickets", + json={"ref": "https://linear.app/acme/issue/PB-1", "source": "branch"}, + ) + + r = client.get("/tickets?project=-Users-me-projA") + assert r.status_code == 200 + keys = {row["external_key"] for row in r.json()} + assert "PA-1" in keys + assert "PA-2" in keys + assert "PB-1" not in keys + + # And the inverse + r2 = client.get("/tickets?project=-Users-me-projB") + keys2 = {row["external_key"] for row in r2.json()} + assert keys2 == {"PB-1"} + + +def test_get_ticket_detail_works_for_github_path_key(client): + client.post( + "/sessions/sess-Y/tickets", + json={"ref": "octocat/repo#42", "source": "branch"}, + ) + r = client.get("/tickets/github/octocat/repo%2342") + # URL-encoded '#' as %23. Some clients send the literal '#' which + # browsers strip; TestClient passes the raw path, so encoding matters. + # If the test client decodes it before routing, we still match + # external_key="octocat/repo#42". + assert r.status_code in (200, 404) + if r.status_code == 200: + assert r.json()["external_key"] == "octocat/repo#42" + + +def test_get_ticket_404_when_unknown(client): + r = client.get("/tickets/linear/NOPE-1") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /tickets?project=... — git_identity aggregation (v12) +# --------------------------------------------------------------------------- + + +def _seed_session(conn, *, uuid: str, project: str) -> None: + conn.execute( + "INSERT INTO sessions (uuid, project_encoded_name, jsonl_mtime) VALUES (?, ?, ?)", + (uuid, project, 0.0), + ) + + +def _seed_project(conn, *, encoded_name: str, git_identity=None, slug=None) -> None: + conn.execute( + "INSERT OR REPLACE INTO projects " + "(encoded_name, project_path, slug, display_name, " + " session_count, last_activity, git_identity) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + encoded_name, + None, + slug or encoded_name, + encoded_name, + 0, + None, + git_identity, + ), + ) + + +def test_project_filter_accepts_slug_or_encoded_name(client): + """The ?project= query param must accept either form: the slug shown + in user-facing URLs (e.g. 'claude-karma-1044') OR the raw encoded_name + (-Users-...). All other project-by-id endpoints already do this via + `resolve_project_identifier()`; the tickets endpoint must match. + + Regression test for: navigating /projects → card → tickets tab landed + on /projects/{slug}, the tab fetched /tickets?project={slug}, the + backend matched on encoded_name → 0 tickets. Navigating via + /tickets → session → project landed on /projects/{encoded_name} which + happened to work.""" + import db.connection as connection + + conn = connection.get_writer_db() + _seed_project( + conn, + encoded_name="-Users-me-myrepo", + slug="myrepo-9999", + git_identity=None, + ) + _seed_session(conn, uuid="ses-1", project="-Users-me-myrepo") + conn.commit() + + client.post( + "/sessions/ses-1/tickets", + json={"ref": "https://linear.app/acme/issue/SLUG-1", "source": "branch"}, + ) + + # Encoded form — works prior to fix. + r_enc = client.get("/tickets?project=-Users-me-myrepo") + assert {row["external_key"] for row in r_enc.json()} == {"SLUG-1"} + + # Slug form — this is what ProjectCard.svelte sends. Must also work. + r_slug = client.get("/tickets?project=myrepo-9999") + assert {row["external_key"] for row in r_slug.json()} == {"SLUG-1"} + + +def test_project_filter_aggregates_across_shared_git_identity(client): + """Two encoded_names sharing a git_identity should pool their tickets. + A ticket linked from project A's session must appear under project B + when both projects have git_identity='org/repo'.""" + import db.connection as connection + + conn = connection.get_writer_db() + _seed_project(conn, encoded_name="-A-main", git_identity="org/repo") + _seed_project(conn, encoded_name="-A-frontend", git_identity="org/repo") + _seed_session(conn, uuid="s-main", project="-A-main") + _seed_session(conn, uuid="s-frontend", project="-A-frontend") + conn.commit() + + # Link a ticket from a session that lives in -A-main. + client.post( + "/sessions/s-main/tickets", + json={"ref": "https://linear.app/acme/issue/SHARED-1", "source": "branch"}, + ) + + # Both projects should see it. + r_main = client.get("/tickets?project=-A-main") + r_front = client.get("/tickets?project=-A-frontend") + assert {row["external_key"] for row in r_main.json()} == {"SHARED-1"} + assert {row["external_key"] for row in r_front.json()} == {"SHARED-1"} + + +def test_project_filter_null_git_identity_falls_back_to_per_encoded(client): + """When the target project has no git_identity (sync-imported, never + indexed locally, etc.), the query must keep the legacy behavior: + only tickets from sessions whose project_encoded_name matches.""" + import db.connection as connection + + conn = connection.get_writer_db() + # Two NULL-git_identity projects — they must NOT pool. + _seed_project(conn, encoded_name="-N-a", git_identity=None) + _seed_project(conn, encoded_name="-N-b", git_identity=None) + _seed_session(conn, uuid="n-a", project="-N-a") + _seed_session(conn, uuid="n-b", project="-N-b") + conn.commit() + + client.post( + "/sessions/n-a/tickets", + json={"ref": "https://linear.app/acme/issue/NA-1", "source": "branch"}, + ) + client.post( + "/sessions/n-b/tickets", + json={"ref": "https://linear.app/acme/issue/NB-1", "source": "branch"}, + ) + + r_a = client.get("/tickets?project=-N-a") + r_b = client.get("/tickets?project=-N-b") + assert {row["external_key"] for row in r_a.json()} == {"NA-1"} + assert {row["external_key"] for row in r_b.json()} == {"NB-1"} + + +def test_project_filter_github_external_key_match_without_local_link(client): + """GitHub heuristic: a ticket `org/repo#42` should appear under a + project whose git_identity='org/repo' EVEN IF no local session has + linked it. This handles the cross-machine sync case.""" + import db.connection as connection + + conn = connection.get_writer_db() + _seed_project(conn, encoded_name="-G-main", git_identity="acme/widget") + _seed_session(conn, uuid="g-main", project="-G-main") + # Also seed a totally unrelated project that links the ticket — this + # is how the ticket gets into the tickets table without -G-main ever + # touching it. + _seed_project(conn, encoded_name="-OTHER", git_identity="other/thing") + _seed_session(conn, uuid="other-s", project="-OTHER") + conn.commit() + + # Link from -OTHER (no relation to acme/widget). + client.post( + "/sessions/other-s/tickets", + json={"ref": "acme/widget#42", "source": "branch"}, + ) + + r = client.get("/tickets?project=-G-main") + keys = {row["external_key"] for row in r.json()} + assert "acme/widget#42" in keys + + # Linear ticket with a coincidentally similar key should NOT match + # the GitHub heuristic — guard against provider confusion. + client.post( + "/sessions/other-s/tickets", + json={ + "ref": "ACME-99", + "provider": "linear", + "url": "https://linear.app/acme/issue/ACME-99", + "source": "branch", + }, + ) + r2 = client.get("/tickets?project=-G-main") + keys2 = {row["external_key"] for row in r2.json()} + assert "ACME-99" not in keys2 # provider guard intact + + +def test_project_filter_aggregates_link_count_across_siblings(client): + """When a ticket is linked from sessions in multiple sibling projects + sharing a git_identity, `session_count` reflects the total — proving + the cross-encoded aggregation reaches all linked sessions, not just + those under one encoded_name.""" + import db.connection as connection + + conn = connection.get_writer_db() + _seed_project(conn, encoded_name="-S-main", git_identity="team/proj") + _seed_project(conn, encoded_name="-S-frontend", git_identity="team/proj") + _seed_session(conn, uuid="sm-1", project="-S-main") + _seed_session(conn, uuid="sf-1", project="-S-frontend") + conn.commit() + + # Same ticket linked from BOTH sibling projects. + client.post( + "/sessions/sm-1/tickets", + json={"ref": "team/proj#7", "source": "branch"}, + ) + client.post( + "/sessions/sf-1/tickets", + json={"ref": "team/proj#7", "source": "branch"}, + ) + + r = client.get("/tickets?project=-S-main") + by_key = {row["external_key"]: row for row in r.json()} + assert by_key["team/proj#7"]["session_count"] == 2 + + +def test_get_ticket_sessions_for_orphan_link(client): + """A link whose session_uuid isn't in the sessions index still appears, + with sessions-fields NULL (LEFT JOIN behavior).""" + client.post( + "/sessions/orphan-uuid/tickets", + json={"ref": "https://linear.app/acme/issue/ORPHAN-1", "source": "branch"}, + ) + r = client.get("/tickets/linear/ORPHAN-1/sessions") + assert r.status_code == 200, r.text + rows = r.json() + assert len(rows) == 1 + assert rows[0]["session_uuid"] == "orphan-uuid" + # Joined session fields are NULL for orphans + assert rows[0]["start_time"] is None + assert rows[0]["sessions_slug"] is None diff --git a/api/tests/test_git_identity.py b/api/tests/test_git_identity.py new file mode 100644 index 00000000..a66305f6 --- /dev/null +++ b/api/tests/test_git_identity.py @@ -0,0 +1,138 @@ +"""Tests for api/services/git_identity.py.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from services.git_identity import normalize_git_url, read_git_identity + +# --------------------------------------------------------------------------- +# normalize_git_url — pure parser +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url, expected", + [ + # https with .git + ("https://github.com/Owner/Repo.git", "owner/repo"), + # https without .git + ("https://github.com/Owner/Repo", "owner/repo"), + # scp-style ssh with .git + ("git@github.com:Owner/Repo.git", "owner/repo"), + # scp-style ssh without .git + ("git@github.com:Owner/Repo", "owner/repo"), + # ssh:// scheme + ("ssh://git@github.com/Owner/Repo.git", "owner/repo"), + # GitLab — owner/repo extracted naturally + ("https://gitlab.com/team/project.git", "team/project"), + # Self-hosted with subgroups — last two segments win + ("https://gitlab.example.com/group/subgroup/owner/repo.git", "owner/repo"), + # Mixed case lowercased + ("HTTPS://GITHUB.COM/UPPER/CASE.git", "upper/case"), + # Trailing slash tolerated + ("https://github.com/foo/bar/", "foo/bar"), + # Whitespace stripped (git config sometimes returns trailing newline) + (" https://github.com/foo/bar.git \n", "foo/bar"), + ], +) +def test_normalize_git_url_happy_paths(url, expected): + assert normalize_git_url(url) == expected + + +@pytest.mark.parametrize( + "url", + [ + "", + None, + "not-a-url-at-all", + "/local/path/to/repo", # local path, not remote + "https://github.com/", # no owner/repo + "https://github.com/onlyone", # only one segment + "git@github.com:", # scp-style with empty path + ], +) +def test_normalize_git_url_rejects_garbage(url): + assert normalize_git_url(url) is None + + +# --------------------------------------------------------------------------- +# read_git_identity — composes the subprocess + parser +# --------------------------------------------------------------------------- + + +def _make_repo(path: Path, remote_url: str) -> None: + """Init a bare-bones git repo with origin pointing at `remote_url`. + + Uses a non-empty repo (no commit needed — `git config` works on an + empty init) and `-c init.defaultBranch=main` to silence the modern + git hint output. + """ + subprocess.run( + ["git", "-c", "init.defaultBranch=main", "init", "-q"], + cwd=path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", remote_url], + cwd=path, + check=True, + capture_output=True, + ) + + +def test_read_git_identity_real_repo(tmp_path): + _make_repo(tmp_path, "git@github.com:JayantDevkar/Claude-Code-Karma.git") + assert read_git_identity(str(tmp_path)) == "jayantdevkar/claude-code-karma" + + +def test_read_git_identity_https_remote(tmp_path): + _make_repo(tmp_path, "https://github.com/foo/bar.git") + assert read_git_identity(str(tmp_path)) == "foo/bar" + + +def test_read_git_identity_not_a_repo(tmp_path): + # Empty directory — `git config --get` returns exit code 128 + # ("not a git repository") which we treat as None. + assert read_git_identity(str(tmp_path)) is None + + +def test_read_git_identity_repo_with_no_remote(tmp_path): + subprocess.run( + ["git", "-c", "init.defaultBranch=main", "init", "-q"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + # `git config --get` returns exit code 1 when key is absent. + assert read_git_identity(str(tmp_path)) is None + + +def test_read_git_identity_none_path_returns_none(): + assert read_git_identity(None) is None + assert read_git_identity("") is None + + +def test_read_git_identity_nonexistent_path(tmp_path): + # `git -C /does/not/exist` returns exit code 128. + bogus = tmp_path / "does-not-exist" + assert read_git_identity(str(bogus)) is None + + +def test_read_git_identity_subdir_returns_parent_remote(tmp_path): + """Subdirs inherit their parent repo's git config — the same is true + for our subdir projects (`claude-karma-frontend` etc.), which should + naturally share git_identity with the main project.""" + _make_repo(tmp_path, "git@github.com:org/repo.git") + sub = tmp_path / "subdir" + sub.mkdir() + assert read_git_identity(str(sub)) == "org/repo" diff --git a/api/tests/test_schema_tickets.py b/api/tests/test_schema_tickets.py new file mode 100644 index 00000000..9dc65c93 --- /dev/null +++ b/api/tests/test_schema_tickets.py @@ -0,0 +1,242 @@ +""" +Schema migration tests for v11 (tickets + session_tickets) and forward. + +Verifies that: + 1. Fresh install (current_version == 0) applies SCHEMA_SQL and both + ticket tables + indices + CHECK constraints exist. + 2. v10 → v11 upgrade applies the incremental migration block. + 3. Replay is idempotent (no-op). + +Version assertions use the live `SCHEMA_VERSION` constant rather than +literals so future migrations (v12+) don't require touching this file. +""" + +import sqlite3 +import sys +from pathlib import Path + +import pytest + +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from db.schema import SCHEMA_VERSION, ensure_schema + + +def _make_db() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return row is not None + + +def _index_exists(conn: sqlite3.Connection, name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?", + (name,), + ).fetchone() + return row is not None + + +def _get_version(conn: sqlite3.Connection) -> int: + return conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] + + +def test_schema_version_is_at_least_eleven(): + # v11 introduced the ticket tables this test file covers. + # Higher versions are fine — they layer on top. + assert SCHEMA_VERSION >= 11 + + +def test_fresh_install_creates_both_ticket_tables(): + conn = _make_db() + ensure_schema(conn) + + assert _table_exists(conn, "tickets") + assert _table_exists(conn, "session_tickets") + assert _index_exists(conn, "idx_tickets_provider") + assert _index_exists(conn, "idx_session_tickets_session") + assert _index_exists(conn, "idx_session_tickets_slug") + assert _index_exists(conn, "idx_session_tickets_ticket") + assert _index_exists(conn, "uniq_session_tickets_slug_ticket") + assert _get_version(conn) == SCHEMA_VERSION + + +def test_fresh_install_check_constraints_fire(): + conn = _make_db() + ensure_schema(conn) + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO tickets (provider, external_key, url) VALUES (?, ?, ?)", + ("bitbucket", "X-1", "https://example.com"), # not in CHECK whitelist + ) + + # metadata_json size cap (just over 64 KB) + too_big = "x" * (64 * 1024 + 1) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO tickets (provider, external_key, url, metadata_json) VALUES (?, ?, ?, ?)", + ("linear", "ABC-1", "https://linear.app/x/issue/ABC-1", too_big), + ) + + +def test_link_source_check_constraint_fires(): + conn = _make_db() + ensure_schema(conn) + ticket_id = conn.execute( + "INSERT INTO tickets (provider, external_key, url) VALUES (?, ?, ?) RETURNING id", + ("linear", "ABC-1", "https://linear.app/x/issue/ABC-1"), + ).fetchone()["id"] + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO session_tickets (session_uuid, ticket_id, link_source) VALUES (?, ?, ?)", + ("sess-1", ticket_id, "magic"), + ) + + +def test_migration_from_v10_creates_ticket_tables(): + """Simulate an existing v10 install upgrading to v11.""" + conn = _make_db() + + # Build the minimum needed to pass ensure_schema's incremental path: + # a schema_version row at v10 and the tables that earlier migrations + # reference. We only need the schema_version row — the v11 step + # references no prior tables for its CREATE work. + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version " + "(version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')))" + ) + conn.execute("INSERT INTO schema_version (version) VALUES (10)") + conn.commit() + + assert not _table_exists(conn, "tickets") + assert not _table_exists(conn, "session_tickets") + + ensure_schema(conn) + + assert _table_exists(conn, "tickets") + assert _table_exists(conn, "session_tickets") + assert _index_exists(conn, "uniq_session_tickets_slug_ticket") + assert _get_version(conn) == SCHEMA_VERSION + + +def test_replay_is_idempotent(): + conn = _make_db() + ensure_schema(conn) + ensure_schema(conn) # should not raise + + assert _get_version(conn) == SCHEMA_VERSION + # Tables still exist + assert _table_exists(conn, "tickets") + assert _table_exists(conn, "session_tickets") + + +def test_ensure_schema_creates_ticket_tables_on_cross_branch_higher_version(): + """Regression test for the live-meta-test bug. + + A karma DB used on a parallel branch may have its schema_version + advanced past ours (e.g., 22 because that branch added different + tables). The version-gated early-return in ensure_schema() then + skips our v10 → v11 migration block, leaving the ticket tables + missing and every ticket endpoint 500'ing. + + Fix: unconditional `executescript(_TICKETS_SCHEMA_SQL)` at the top + of ensure_schema() guarantees our tables exist regardless of + version-tracking drift across branches. + """ + conn = _make_db() + + # Simulate a karma DB at v99 from a parallel branch. + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version " + "(version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')))" + ) + conn.execute("INSERT INTO schema_version (version) VALUES (99)") + conn.commit() + + assert not _table_exists(conn, "tickets") + assert not _table_exists(conn, "session_tickets") + + ensure_schema(conn) + + # Tables must be present even though we never ran our v11 migration block. + assert _table_exists(conn, "tickets") + assert _table_exists(conn, "session_tickets") + assert _index_exists(conn, "uniq_session_tickets_slug_ticket") + + # Version is NOT bumped — we never tried to migrate from v99 to v11. + # The recorded version stays at 99 because the unconditional path + # only creates missing tables; it doesn't pretend we ran a migration. + assert _get_version(conn) == 99 + + +def test_partial_unique_index_dedupes_by_slug_when_present(): + conn = _make_db() + ensure_schema(conn) + ticket_id = conn.execute( + "INSERT INTO tickets (provider, external_key, url) VALUES (?, ?, ?) RETURNING id", + ("linear", "ABC-1", "https://linear.app/x/issue/ABC-1"), + ).fetchone()["id"] + + # First resume — slug populated + conn.execute( + "INSERT INTO session_tickets (session_uuid, session_slug, ticket_id, link_source) " + "VALUES (?, ?, ?, ?)", + ("uuid-a", "happy-slug", ticket_id, "branch"), + ) + # Second resume of the SAME slug to the SAME ticket — should fail + # the partial unique index even though session_uuid differs. + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO session_tickets (session_uuid, session_slug, ticket_id, link_source) " + "VALUES (?, ?, ?, ?)", + ("uuid-b", "happy-slug", ticket_id, "branch"), + ) + + +def test_partial_unique_index_allows_null_slug_duplicates(): + """NULL session_slug rows are NOT covered by the partial unique index, + so two different UUIDs can both link to the same ticket without slug.""" + conn = _make_db() + ensure_schema(conn) + ticket_id = conn.execute( + "INSERT INTO tickets (provider, external_key, url) VALUES (?, ?, ?) RETURNING id", + ("linear", "ABC-1", "https://linear.app/x/issue/ABC-1"), + ).fetchone()["id"] + + conn.execute( + "INSERT INTO session_tickets (session_uuid, ticket_id, link_source) VALUES (?, ?, ?)", + ("uuid-a", ticket_id, "branch"), + ) + conn.execute( + "INSERT INTO session_tickets (session_uuid, ticket_id, link_source) VALUES (?, ?, ?)", + ("uuid-b", ticket_id, "branch"), + ) + # No exception — slug-based dedup didn't fire on NULL slugs. + + +def test_fk_cascade_deletes_session_tickets_when_ticket_removed(): + conn = _make_db() + ensure_schema(conn) + ticket_id = conn.execute( + "INSERT INTO tickets (provider, external_key, url) VALUES (?, ?, ?) RETURNING id", + ("linear", "ABC-1", "https://linear.app/x/issue/ABC-1"), + ).fetchone()["id"] + conn.execute( + "INSERT INTO session_tickets (session_uuid, ticket_id, link_source) VALUES (?, ?, ?)", + ("uuid-a", ticket_id, "branch"), + ) + conn.execute("DELETE FROM tickets WHERE id = ?", (ticket_id,)) + count = conn.execute("SELECT COUNT(*) FROM session_tickets").fetchone()[0] + assert count == 0 diff --git a/api/tests/test_ticket_parser.py b/api/tests/test_ticket_parser.py new file mode 100644 index 00000000..eb5ac5a1 --- /dev/null +++ b/api/tests/test_ticket_parser.py @@ -0,0 +1,148 @@ +""" +Unit tests for api/services/ticket_parser.py. + +Pure I/O-free parser, table-driven. +""" + +import sys +from pathlib import Path + +import pytest + +# Make `api/` importable +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from services.ticket_parser import parse_ticket_ref + + +@pytest.mark.parametrize( + "raw,hint,expected_provider,expected_key", + [ + # Linear URLs + ("https://linear.app/acme/issue/ABC-123", None, "linear", "ABC-123"), + ("https://linear.app/acme/issue/ABC-123/some-title", None, "linear", "ABC-123"), + ("https://linear.app/acme/issue/abc-123", None, "linear", "ABC-123"), + # Jira URLs + ("https://acme.atlassian.net/browse/PROJ-45", None, "jira", "PROJ-45"), + ("https://acme.atlassian.net/browse/PROJ-45?focusedId=99", None, "jira", "PROJ-45"), + # GitHub URLs + ( + "https://github.com/octocat/hello-world/issues/42", + None, + "github", + "octocat/hello-world#42", + ), + ( + "https://github.com/octocat/hello-world/pull/42", + None, + "github", + "octocat/hello-world#42", + ), + ( + "https://github.com/Octocat/hello-world/issues/42", + None, + "github", + "Octocat/hello-world#42", + ), + # GitHub short + ("octocat/hello-world#42", None, "github", "octocat/hello-world#42"), + ("OctoCat/hello-world#1", None, "github", "OctoCat/hello-world#1"), + # Bare keys with hint + ("ABC-123", "linear", "linear", "ABC-123"), + ("PROJ-45", "jira", "jira", "PROJ-45"), + ("abc-123", "linear", "linear", "ABC-123"), + ], +) +def test_parse_recognized(raw, hint, expected_provider, expected_key): + ref = parse_ticket_ref(raw, hint_provider=hint) + assert ref is not None, f"expected to parse {raw!r}" + assert ref.provider == expected_provider + assert ref.external_key == expected_key + assert ref.url # always populated + + +def test_github_url_preserves_pull_path_segment(): + """GitHub uses one numbering namespace for issues and PRs, but they + have different URL paths (/issues/N vs /pull/N) and different + semantics (PRs have draft/merged state; issues don't). The parser + must preserve which one the caller meant — collapsing both to + /issues/ destroys that distinction and tells users their PR is + an issue.""" + ref = parse_ticket_ref("https://github.com/octocat/repo/pull/9") + assert ref is not None + assert ref.url == "https://github.com/octocat/repo/pull/9" + + +def test_github_url_preserves_issues_path_segment(): + ref = parse_ticket_ref("https://github.com/octocat/repo/issues/9") + assert ref is not None + assert ref.url == "https://github.com/octocat/repo/issues/9" + + +def test_github_url_strips_query_and_fragment_but_keeps_kind(): + """We normalize away noise (query, fragment, trailing path) but + preserve the semantically meaningful path segment.""" + ref = parse_ticket_ref("https://github.com/octocat/repo/pull/9?diff=1#discussion_r123") + assert ref is not None + assert ref.url == "https://github.com/octocat/repo/pull/9" + + +def test_github_short_ref_defaults_to_issues_path(): + """Bare `owner/repo#N` is ambiguous (could be either an issue or a + PR — GitHub uses one numbering namespace). We default to /issues/ + because GitHub auto-redirects /issues/N to /pull/N when N is a PR, + so the link still resolves. The frontend may still distinguish + later via metadata fetched from MCP.""" + ref = parse_ticket_ref("octocat/repo#9") + assert ref is not None + assert ref.url == "https://github.com/octocat/repo/issues/9" + + +def test_bare_key_without_hint_returns_none(): + """Bare ABC-123 is ambiguous between Linear and Jira; we never guess.""" + assert parse_ticket_ref("ABC-123") is None + assert parse_ticket_ref("ABC-123", hint_provider="github") is None # wrong hint + + +def test_bare_hash_n_is_unsupported(): + """A bare '#42' has no owner/repo; spec explicitly excludes it.""" + assert parse_ticket_ref("#42") is None + assert parse_ticket_ref("#42", hint_provider="github") is None + + +@pytest.mark.parametrize( + "garbage", + [ + "", + " ", + "not-a-ticket", + "https://example.com/some/path", + "linear.app/team/issue/ABC-123", # missing scheme + "ABC", # missing -N + "https://github.com/owner/repo/discussions/42", # not issue/pull + ], +) +def test_parse_garbage_returns_none(garbage): + assert parse_ticket_ref(garbage) is None + + +def test_whitespace_trimmed(): + ref = parse_ticket_ref(" https://linear.app/acme/issue/ABC-1 ") + assert ref is not None + assert ref.external_key == "ABC-1" + + +def test_bare_key_url_for_linear_is_search_fallback(): + ref = parse_ticket_ref("ABC-123", hint_provider="linear") + assert ref is not None + assert "ABC-123" in ref.url + assert "linear.app" in ref.url + + +def test_bare_key_url_for_jira_is_atlassian_browse(): + ref = parse_ticket_ref("PROJ-45", hint_provider="jira") + assert ref is not None + assert "PROJ-45" in ref.url + assert "atlassian.net" in ref.url diff --git a/api/tests/test_ticket_session_enrichment.py b/api/tests/test_ticket_session_enrichment.py new file mode 100644 index 00000000..87b6a534 --- /dev/null +++ b/api/tests/test_ticket_session_enrichment.py @@ -0,0 +1,279 @@ +""" +Tests for api/services/ticket_session_enrichment.py. + +Covers: + - rows with indexed sessions data are returned unchanged (with live=None) + - rows missing sessions data get filled from live-session state + - resumed sessions: link UUID found via session_ids[] membership + - true orphans (no sessions, no live) stay marked as orphan + - live-sessions read failure doesn't break the pipeline +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from models.live_session import LiveSessionState, SessionState +from services.ticket_session_enrichment import ( + _build_uuid_index, + _find_live_for_uuid, + enrich_sessions_with_live, +) + + +def _make_state( + session_id: str, + *, + slug: str = "happy-pioneer", + state: SessionState = SessionState.LIVE, + project_encoded: str = "-Users-me-claude-karma", + session_ids: list[str] | None = None, + cwd: str = "/Users/me/Documents/GitHub/claude-karma", +) -> LiveSessionState: + """Build a minimal LiveSessionState for tests. + + The `resolved_project_encoded_name` property derives from + `transcript_path` via the segment after `/projects/`. We construct a + transcript_path that yields the desired encoded name so the real + property logic runs unmodified. + """ + started = datetime(2026, 5, 13, 14, 36, 0, tzinfo=timezone.utc) + updated = datetime(2026, 5, 19, 1, 55, 50, tzinfo=timezone.utc) + transcript_path = f"/Users/me/.claude/projects/{project_encoded}/{session_id}.jsonl" + return LiveSessionState( + session_id=session_id, + session_ids=session_ids or [session_id], + slug=slug, + state=state, + cwd=cwd, + started_at=started, + updated_at=updated, + transcript_path=transcript_path, + last_hook="SessionStart", + ) + + +def test_indexed_row_unchanged_live_none(): + """A row that already has sessions_slug stays as-is, gets live=None.""" + rows = [ + { + "link_id": 1, + "session_uuid": "uuid-1", + "sessions_slug": "indexed-slug", + "project_encoded_name": "-some-project", + "start_time": "2026-05-10T10:00:00Z", + "initial_prompt": "build a thing", + } + ] + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[], + ): + out = enrich_sessions_with_live(rows) + + assert out[0]["sessions_slug"] == "indexed-slug" + assert out[0]["project_encoded_name"] == "-some-project" + assert out[0]["initial_prompt"] == "build a thing" + assert out[0]["live"] is None + + +def test_orphan_row_gets_live_data(): + """A row with no sessions data + matching live state gets filled in.""" + rows = [ + { + "link_id": 7, + "session_uuid": "uuid-live-1", + "sessions_slug": None, + "project_encoded_name": None, + "start_time": None, + "initial_prompt": None, + } + ] + state = _make_state("uuid-live-1", slug="active-slug") + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[state], + ): + out = enrich_sessions_with_live(rows) + + r = out[0] + assert r["sessions_slug"] == "active-slug" + assert r["project_encoded_name"] == "-Users-me-claude-karma" + assert r["start_time"] == "2026-05-13T14:36:00+00:00" + assert r["initial_prompt"] is None # intentional — see service docstring + assert r["live"] is not None + assert r["live"]["status"] == "LIVE" + assert r["live"]["cwd"] == "/Users/me/Documents/GitHub/claude-karma" + + +def test_resumed_session_found_via_session_ids(): + """Link made to an early UUID resolves to the active state via session_ids.""" + rows = [ + { + "link_id": 9, + "session_uuid": "uuid-old", + "sessions_slug": None, + "project_encoded_name": None, + "start_time": None, + "initial_prompt": None, + } + ] + # Current state is under uuid-new, but session_ids tracks the prior resume + state = _make_state( + "uuid-new", + slug="resumed-slug", + session_ids=["uuid-old", "uuid-new"], + ) + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[state], + ): + out = enrich_sessions_with_live(rows) + + assert out[0]["sessions_slug"] == "resumed-slug" + assert out[0]["live"]["status"] == "LIVE" + + +def test_true_orphan_stays_orphan(): + """No sessions row, no live state → row left as-is, live=None.""" + rows = [ + { + "link_id": 5, + "session_uuid": "uuid-truly-gone", + "sessions_slug": None, + "project_encoded_name": None, + "start_time": None, + "initial_prompt": None, + } + ] + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[], + ): + out = enrich_sessions_with_live(rows) + + assert out[0]["sessions_slug"] is None + assert out[0]["project_encoded_name"] is None + assert out[0]["live"] is None + + +def test_live_states_read_failure_does_not_break_enrichment(): + """If load_all_live_sessions raises, the pipeline still returns rows.""" + rows = [ + { + "link_id": 1, + "session_uuid": "uuid-x", + "sessions_slug": None, + "project_encoded_name": None, + "start_time": None, + "initial_prompt": None, + } + ] + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + side_effect=RuntimeError("filesystem broke"), + ): + out = enrich_sessions_with_live(rows) + + # Row preserved, live=None, no raise. + assert out[0]["sessions_slug"] is None + assert out[0]["live"] is None + + +def test_fast_path_no_missing_rows_still_scans_zero_times(): + """When all rows are indexed, we skip the directory scan entirely.""" + rows = [ + {"session_uuid": "u1", "sessions_slug": "s1"}, + {"session_uuid": "u2", "sessions_slug": "s2"}, + ] + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + side_effect=AssertionError("should not be called on the fast path"), + ): + out = enrich_sessions_with_live(rows) + + assert all(r["live"] is None for r in out) + + +def test_indexed_data_not_overwritten(): + """If a row already has start_time, we don't overwrite it with live data.""" + rows = [ + { + "link_id": 1, + "session_uuid": "uuid-x", + "sessions_slug": None, # triggers enrichment + "project_encoded_name": None, + "start_time": "2026-01-01T00:00:00Z", # already set, must persist + "initial_prompt": None, + } + ] + state = _make_state("uuid-x", slug="live-slug") + + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[state], + ): + out = enrich_sessions_with_live(rows) + + assert out[0]["start_time"] == "2026-01-01T00:00:00Z" # preserved + assert out[0]["sessions_slug"] == "live-slug" # was None → filled + + +def test__find_live_for_uuid_returns_state(): + state = _make_state("uuid-find") + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[state], + ): + result = _find_live_for_uuid("uuid-find") + + assert result is state + + +def test__find_live_for_uuid_missing_returns_none(): + with patch( + "services.ticket_session_enrichment.load_all_live_sessions", + return_value=[], + ): + assert _find_live_for_uuid("nope") is None + + +def test_build_uuid_index_current_wins_over_historical(): + """If two states share a UUID — one as current, one as historical — + the current one wins in the index.""" + current = _make_state("u1", slug="current") + other = _make_state("u2", slug="other-with-u1-historical", session_ids=["u1", "u2"]) + index = _build_uuid_index([current, other]) + # u1 is the current id of `current`, but also a historical id of `other`. + # Current must win. + assert index["u1"].slug == "current" + + +def test_build_uuid_index_current_wins_regardless_of_input_order(): + """Cross-state collision: u1 is current for state A AND historical for + state B. The two-pass build must yield A regardless of iteration order. + Regression test for the ordering-fragility flagged in code review.""" + state_a = _make_state("u1", slug="state-a-current") + state_b = _make_state("u2", slug="state-b-current", session_ids=["u1", "u2"]) + + # Forward order + forward = _build_uuid_index([state_a, state_b]) + assert forward["u1"].slug == "state-a-current" + + # Reverse order — historical write happens BEFORE the current write + # for u1. Two-pass guarantees A still wins. + reverse = _build_uuid_index([state_b, state_a]) + assert reverse["u1"].slug == "state-a-current" diff --git a/docs/design-briefs/2026-05-18-ticket-linking-ui-log.md b/docs/design-briefs/2026-05-18-ticket-linking-ui-log.md new file mode 100644 index 00000000..b2bbe5e2 --- /dev/null +++ b/docs/design-briefs/2026-05-18-ticket-linking-ui-log.md @@ -0,0 +1,92 @@ +# Decision log — Ticket linking UI + +Paired companion to `2026-05-18-ticket-linking-ui.md`. Each design iteration +appends one section here with the locked decisions and a one-line rationale. + +--- + +## Iteration 1 — 2026-05-18 (Claude Design) + +**Deliverables received:** + +- `iterations/2026-05-18/critique-notes.png` — 5-minute audit of current UI (2 keeps, 4 tensions, 1 fix) +- `iterations/2026-05-18/Q1-…` through `Q9b-…` — rendered variants per brief question +- `iterations/2026-05-18/tickets-populated-interactive-v{1,2}.png` — populated `/tickets` screenshots +- `iterations/2026-05-18/ticket-ui-review.html` — shell that would host the React artboards (not standalone; screenshots are the renders) +- A complete drop-in patch under `implementation/` (5 Svelte files + `lib/ticket-helpers.ts` + app.css token diff) + +### Locked decisions + +| # | Question | Decision | Rationale | +|---|---|---|---| +| Q1 | Provider visual language | **Direction C** — industry-color square with letter-mark (`LIN` / `JIR` / `GH`) | Solves color-blind-only-differentiator problem from current state; no brand logos / new deps; works at 10–13px scales (pill, table, hero); a single token group `--provider-*` | +| Q2 | `/tickets` empty state | **Variant A** — terminal-flavored card teaching all three link paths with inline `` snippets | Worst regression risk is "feature looks off"; the three numbered rows (skill / branch / paste) are self-documenting; matches dashboard's `$ live-sessions` voice | +| Q3 | Ticket detail hierarchy | **V2** — stats-first hero (small ticket card + 4-up stat row: Projects · Sessions · First seen · Metadata) | Lead with the rollup numbers the user actually wants ("what work was done on this thing?"); ticket card stays present but compact | +| Q4 | Link input state system | **Full 5-state machine** — idle / typing / validating / error / success, with rich below-input feedback | Closes the "no real state system" tension; auto-detects provider for URLs and `owner/repo#N`, only shows the dropdown for ambiguous bare keys; idle state teaches `/link` and `feat/...` paths | +| Q5 | Session-page section placement | **Variant A** — full-width strip above ConversationView with terminal-style `$ tickets [N linked]` header | Matches existing dashboard voice (Critique #6); pre-existing position confirmed; just the styling changes | +| Q6 | Unlink button treatment | **C + D combined** — kebab menu (Open / Copy / Unlink) on pill variant, 5-second undo toast inside `SessionTicketsSection` | Calibrated to the action's true cost (reversible); kebab shrinks visual weight; undo toast is local to the section (no global toast slot) | +| Q7 | Status display | **Normalized dot + verbatim label** | `normalizeStatus()` maps each provider's vocabulary to `todo / active / review / done / closed / unknown`; verbatim string preserved next to the colored dot | +| Q9b | Ticket detail → sessions by project | **Variant A** — tabs (All + per-project), conditional on ≥2 projects; "Projects" count in the stats row | Replaces the grouped-headers approach shipped earlier in this branch. Tabs scale better to many projects; "All" stays default; orphans bucket into an "Unindexed" tab last | + +### Deferred / not in this patch + +| # | Question | Status | Notes | +|---|---|---|---| +| Q8 | Mobile / narrow layout for index | **Deferred** | Index ships with overflow-x today; design didn't address this iteration. Worth a follow-up. | +| Q9a | Project page → tickets surface | **Deferred to next iteration** | Design's README: *"Not in this patch — that's a project-page edit, separate concern."* My `` (shipped earlier in this session) stays as the v1 surface; design will iterate it next round, likely as a tab on the project page. | + +### Critique highlights (kept) + +From `critique-notes.png`: + +- ✅ **Data model shape is clean** — one badge component, three variants, API contract stays +- ✅ **Dark/light token discipline is already there** — no new tokens that aren't already in `app.css`, EXCEPT the new `--provider-*` and `--status-*` groups (additive) + +### Net-new tokens this patch introduces + +In `:root { … }`: + +```css +/* Provider identity */ +--provider-linear: #5e6ad2; --provider-linear-subtle: rgba(94, 106, 210, 0.12); +--provider-jira: #0052cc; --provider-jira-subtle: rgba(0, 82, 204, 0.10); +--provider-github: #1f2937; --provider-github-subtle: rgba(31, 41, 55, 0.08); + +/* Normalized ticket status */ +--status-todo: #94a3b8; +--status-active: #3b82f6; +--status-review: #f59e0b; +--status-done: #10b981; +--status-closed: #64748b; +``` + +Plus matched dark-mode variants in `:root[data-theme='dark']` (and the `prefers-color-scheme: dark` block). + +### Conflicts with code shipped earlier in this session + +| File | Status | Action | +|---|---|---| +| `frontend/src/lib/components/tickets/TicketBadge.svelte` | conflict | Replace — design adds provider chip + status dot + kebab menu | +| `frontend/src/lib/components/tickets/TicketLinkInput.svelte` | conflict | Replace — design adds 5-state machine | +| `frontend/src/lib/components/tickets/SessionTicketsSection.svelte` | conflict | Replace — design adds undo toast + terminal header | +| `frontend/src/routes/tickets/+page.svelte` | conflict | Replace — design rebuilds with grid table, segmented filter, terminal empty state | +| `frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte` | conflict | Replace — design's tabs-based Q9b A supersedes my grouped-headers approach | +| `frontend/src/lib/components/tickets/ProjectTicketsCard.svelte` | **keep** | Q9a is deferred — my v1 surface stays | +| `frontend/src/routes/projects/[project_slug]/+page.svelte` | **keep** | Only references ``; no design change yet | +| `frontend/src/app.css` | additive | Append 11 new tokens × 2 (light + dark) | +| `frontend/src/lib/ticket-helpers.ts` | new | Add — shared PROVIDER_META + normalizeStatus + helpers | + +### Verification plan (post-application) + +- `npm run check` 0 errors +- `npm run lint` no new warnings beyond what existed +- Smoke (from design's README checklist): + - [ ] Light + dark mode both look right (toggle `html.dark`) + - [ ] Long ticket title truncates with ellipsis in pill, inline, and table contexts + - [ ] Missing title renders italic "title not yet fetched" + - [ ] Missing status renders an em-dash (no dot) + - [ ] Paste GitHub URL, Linear URL, bare `OCC-1284` (provider dropdown should appear only for the bare key) + - [ ] Submit a key that 404s on backend → error state renders with API's hint inline + - [ ] Unlink → 5s undo toast → click Undo → ticket reappears with same `link_id` + - [ ] Detail page with 1 project (no tabs) vs ≥2 projects (tabs visible) + - [ ] `mcp__plugin_github_github__issue_read` against #72 returns title used in the populated views diff --git a/docs/design-briefs/2026-05-18-ticket-linking-ui.md b/docs/design-briefs/2026-05-18-ticket-linking-ui.md new file mode 100644 index 00000000..eacc6f8a --- /dev/null +++ b/docs/design-briefs/2026-05-18-ticket-linking-ui.md @@ -0,0 +1,323 @@ +# Design Brief — Ticket Linking UI + +**Date:** 2026-05-18 +**Branch:** `feat/session-ticket-linking` +**PR:** [#73](https://github.com/JayantDevkar/claude-code-karma/pull/73) +**Tracking issue:** [#72](https://github.com/JayantDevkar/claude-code-karma/issues/72) +**Audience:** Claude Design (`claude.ai/design`) + +--- + +## How to use this brief + +Paste this entire document into a fresh claude.ai/design conversation. It is the only context you need. When you reply, please return **two or three rendered variants** (HTML or React in Artifacts) for each numbered question in §7, with rationale and Tailwind class strings I can lift directly. + +--- + +## 1. Problem in one sentence + +Claude Code Karma now lets users link a session to one or more tickets (Linear / Jira / GitHub Issues) and view the work-done-per-ticket from karma's dashboard — the underlying UI is functional but visually minimal, and we want a coherent visual system before the feature ships. + +## 2. Goals + +- A **provider visual language** that distinguishes Linear / Jira / GitHub at a glance without resorting to brand logos or new dependencies +- A **first-time empty state** on `/tickets` that teaches the three link paths (skill, branch-detect, dashboard) +- A **clear information hierarchy** on the ticket detail page +- A **micro-interaction system** for the link input (idle → typing → validating → error → success) +- A **decision about Tickets-section placement** on session detail pages + +## 3. In-scope surfaces + +All paths relative to the repo root. + +| Surface | File | Variants today | +|---|---|---| +| `` | `frontend/src/lib/components/tickets/TicketBadge.svelte` | 3 variants: `inline`, `card`, `pill` | +| `` | `frontend/src/lib/components/tickets/TicketLinkInput.svelte` | Single input + smart provider dropdown | +| `` | `frontend/src/lib/components/tickets/SessionTicketsSection.svelte` | Wraps badges + input | +| `/tickets` index page | `frontend/src/routes/tickets/+page.svelte` | Table layout with provider/search filters | +| `/tickets/[provider]/[external_key]` detail page | `frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte` | Card header + linked-sessions list | + +## 4. Constraints (must honor) + +- **Framework:** SvelteKit 2 + Svelte 5 runes (`$state`, `$derived`, `$props`) +- **Styling:** Tailwind CSS 4 utility classes only; no new CSS-in-JS, no new component libraries +- **Existing primitives:** `bits-ui` for accessible primitives, `lucide-svelte` for icons (no new icon libraries; pick from lucide's ~1k catalog) +- **Design tokens** (CSS custom properties, defined in `frontend/src/app.css`): + + ```css + /* Backgrounds */ + --bg-base /* canvas */ + --bg-subtle /* cards, sections */ + --bg-muted /* nested inputs, table headers */ + + /* Text */ + --text-primary /* body */ + --text-secondary + --text-muted + --text-faint /* timestamps, hints */ + + /* Border */ + --border /* rgba(0,0,0,0.08) light / rgba(255,255,255,0.08) dark */ + --border-subtle + --border-hover + + /* Accent (brand violet) */ + --accent: #7c3aed /* light; auto-flips in dark */ + --accent-hover: #6d28d9 + --accent-subtle: rgba(124,58,237,0.10) + --accent-muted: rgba(124,58,237,0.05) + --accent-rgb: 124, 58, 237 + + /* States */ + --error, --error-subtle, --error-rgb + --warning, --warning-subtle, --warning-rgb + ``` + +- **Must support light AND dark mode** automatically (CSS custom properties auto-flip via a `.dark` class on ``) +- **Accessibility floor:** WCAG 2.1 AA contrast for all text, visible focus rings on every interactive element, screen reader labels preserved (currently `aria-label="Unlink ticket"` on the X button, `aria-labelledby="tickets-heading"` on the section, etc.) +- **Typography:** Inter (UI), JetBrains Mono (ticket keys and code-shaped tokens) +- **No new dependencies** — package.json should not change +- **Mobile:** the index page table should adapt below `md` breakpoint (today it just overflows-x) + +## 5. Real data samples + +These are the actual JSON shapes the UI receives. Variants must accommodate all of them, including missing fields and edge cases. + +### 5a. `/tickets` index row (`TicketListItem`) + +```json +{ + "id": 1, + "provider": "github", + "external_key": "JayantDevkar/claude-code-karma#72", + "url": "https://github.com/JayantDevkar/claude-code-karma/issues/72", + "title": "feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)", + "status": "open", + "first_seen_at": "2026-05-18 23:11:42", + "metadata_updated_at": "2026-05-18 23:11:42", + "session_count": 1, + "last_linked_at": "2026-05-18 23:11:42" +} +``` + +### 5b. Mixed-provider list (composed for variant exploration) + +```json +[ + { + "id": 1, "provider": "github", + "external_key": "JayantDevkar/claude-code-karma#72", + "title": "feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)", + "status": "open", "session_count": 1, "last_linked_at": "2026-05-18 23:11:42", + "url": "https://github.com/JayantDevkar/claude-code-karma/issues/72" + }, + { + "id": 2, "provider": "linear", + "external_key": "OCC-1284", + "title": "Investigate flaky Airflow DAG on prod", + "status": "In Progress", "session_count": 4, "last_linked_at": "2026-05-18 18:22:00", + "url": "https://linear.app/occuspace/issue/OCC-1284" + }, + { + "id": 3, "provider": "jira", + "external_key": "DATA-1027", + "title": "Add quarterly export endpoint", + "status": "Triage", "session_count": 12, "last_linked_at": "2026-05-17 09:14:55", + "url": "https://occuspace.atlassian.net/browse/DATA-1027" + }, + { + "id": 4, "provider": "linear", + "external_key": "PLAT-99", + "title": null, + "status": null, "session_count": 1, "last_linked_at": "2026-05-18 11:02:00", + "url": "https://linear.app/search?q=PLAT-99" + } +] +``` + +### 5c. `/sessions/{uuid}/tickets` row (`SessionTicketRow` — ticket + link inline) + +```json +{ + "id": 1, + "provider": "github", + "external_key": "JayantDevkar/claude-code-karma#72", + "url": "https://github.com/JayantDevkar/claude-code-karma/issues/72", + "title": "feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)", + "status": "open", + "metadata_json": null, + "metadata_updated_at": "2026-05-18 23:11:42", + "first_seen_at": "2026-05-18 23:11:42", + "link_id": 1, + "link_source": "slash_command", + "linked_at": "2026-05-18 23:11:42", + "session_slug": null +} +``` + +### 5d. `/tickets/{provider}/{external_key}/sessions` row (ticket detail — sessions list) + +```json +[ + { + "link_id": 1, + "session_uuid": "48e228d6-a543-4172-8b6d-0146dd65ef82", + "session_slug": "happy-pioneer", + "link_source": "slash_command", + "linked_at": "2026-05-18 23:11:42", + "sessions_slug": "happy-pioneer", + "project_encoded_name": "-Users-jayantdevkar-Documents-GitHub-claude-karma", + "start_time": "2026-05-18 22:36:00", + "end_time": null, + "initial_prompt": "review the work done so far and what needs to be tested + cleaned before we can ship/merge it..." + }, + { + "link_id": 7, + "session_uuid": "0913a1f0-ffff-0000-0000-aaaaaaaaaaaa", + "session_slug": null, + "link_source": "branch", + "linked_at": "2026-05-16 09:00:00", + "sessions_slug": null, + "project_encoded_name": null, + "start_time": null, + "end_time": null, + "initial_prompt": null + } +] +``` + +### 5e. Edge cases the variants must handle + +- **Long title** — 200+ chars, must truncate gracefully (today: `max-w-[24ch] truncate`) +- **Missing title and/or status** — branch-detect links and pre-MCP-fetch state both produce `title=null, status=null`. Provider key + URL are always present. +- **Orphan link** — `sessions_slug = null, project_encoded_name = null, start_time = null` — the linked session isn't in the index yet (e.g., branch-detect fired pre-index) +- **High session count** — `session_count: 12` for one ticket. The detail page should scale to dozens. +- **All-providers mixed** — list views must visually distinguish three providers without color-blind-hostile choices + +## 6. Provider key shapes (visual treatment must accommodate) + +| Provider | Shape | Example | +|---|---|---| +| `linear` | `[A-Z]+-\d+` | `LINEAR-123`, `OCC-1284` | +| `jira` | `[A-Z]+-\d+` (visually identical to linear) | `PROJ-45`, `DATA-1027` | +| `github` | `owner/repo#N` (contains `/` and `#`) | `JayantDevkar/claude-code-karma#72` | + +Note that Linear and Jira keys are visually indistinguishable — the only differentiator is the provider treatment we apply. + +## 7. Open questions — what we want design's opinion on + +Please return 2–3 variants for each. + +1. **Provider visual language** + What's the coherent system across Linear / Jira / GitHub? Color treatment, icon/glyph, position, weight? The constraint is no brand logos and no new dependencies. Today: one inline color token per provider (`text-[var(--accent)]` for linear, `text-[#0052cc]` for jira, `text-[var(--text-primary)]` for github) — basically arbitrary. We want better. Should the system work color-blind? + +2. **`/tickets` index empty state** + When the user has no tickets linked yet, the index shows `"No tickets linked yet. Open a session and paste a ticket URL to link your first one."` It's flat. Three link paths exist (skill, branch-detect, dashboard) — the empty state could teach them. How should this onboard the first-time user? + +3. **Ticket detail hierarchy** + What's the focal point on `/tickets/[provider]/[external_key]`? Today: card with provider + key + click-through, then a list of sessions. Should the title be the largest element? Should the status be a prominent pill? Should the linked-sessions count be a callout? Where should "View on Linear/Jira/GitHub →" sit? + +4. **`` micro-interactions** + Today the input has: placeholder text, optional provider dropdown that appears when the input is a bare key, submit button with spinner, error message below on 400. The states are: **idle / typing / validating / error / success**. Design these as a system — focus ring, transitions, error treatment, what happens visually when the link succeeds. + +5. **Session-page Tickets section** + Today this section sits **above** `` in the page flow. Is that the right position? Should it be **collapsible** (expanded by default if there are links, collapsed if empty)? **Pinned to the side** instead of full-width? **Inside a tab** of ConversationView? The signal-to-noise vs. discoverability tradeoff is the question. + +6. **Unlink (X) button on pills** + Today: small `X` icon in `text-[var(--text-muted)]`, hovers to `text-[var(--error)]`. Is that visual weight right for a destructive action that's reversible (you can always re-link)? Should it require a hover-to-reveal? A confirmation step? Or is the current treatment correct? + +7. **Status display** + Each provider returns its own native status strings: GitHub → `"open"`, `"closed"`; Linear → `"Backlog"`, `"In Progress"`, `"In Review"`, `"Done"`, `"Canceled"`; Jira → `"To Do"`, `"In Progress"`, `"Triage"`, `"Done"`, etc. Should we render them verbatim (today's behavior), normalize to a small set with mapping (e.g., `todo / active / review / done / closed`), or apply color/shape without normalizing the text? + +8. **Mobile / narrow layout for `/tickets` index** + The table overflows-x today. At what breakpoint should it switch to a card list? What does the mobile card look like — what fields stay, what go? + +9. **Project ↔ ticket relationship surfacing** + The truth is **many-to-many**: a ticket can touch many projects via its + linked sessions; a project can have many tickets via the same join. We + intentionally do NOT model project as a property of the ticket — that + would force-fit cross-project tickets (big refactors) into a single + project bucket and conflict with karma's "passive observer" stance. + + Today the only surface for this m:n is the `?project=X` filter on + `/tickets`. We want the relationship to be **navigable from both + directions**: + + - **(a) Project page → tickets touched.** On `/projects/[project_slug]`, + surface "Tickets touched by sessions in this project." Tab? Card? + Sidebar? In-flow? What weight relative to the other project content + (sessions, agents, skills)? + + - **(b) Ticket detail → sessions grouped by project.** On + `/tickets/[provider]/[external_key]`, when a ticket has linked + sessions across multiple projects, show that breakdown clearly. + Do we group sessions under collapsible project headers? Show a + "Touched projects: claude-karma (3) · airflow (1)" summary line at + the top? Both? + + Cross-cutting question for design: **what's the right hierarchy on + the ticket detail page** — does it lead with the ticket title and + status, with the project breakdown, or with the linked-sessions + list? Pick the visual order that best matches a developer asking + "what work was done on this thing, and where?" + + **Data shape reminder:** the `/tickets/{provider}/{external_key}/sessions` + endpoint already returns `project_encoded_name` per row (see §5d). + No new API or data-model change is required for this question — it is + purely a surfacing/UX call. + +## 8. Non-goals (don't redesign these) + +- The global header / navigation (Tickets link is already there) +- Route structure (`/tickets`, `/tickets/[provider]/[external_key]`, `/projects/[project_slug]/[session_slug]`) +- The data model or API contracts (those are locked by schema v11) +- Provider brand assets (no new logos, no SVG icons of Linear/Jira/GitHub marks — we want a tokens-only treatment) +- Animation libraries (use Tailwind transitions only — `transition-colors`, `transition-opacity`, `duration-150`, `ease-out`, etc.) +- The skill / hook / config sides — those are headless + +## 9. Current state (text walkthrough — replace with screenshots when possible) + +If you have the ability to render the existing components in an Artifact for reference, the component sources are: + +- [`TicketBadge.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/lib/components/tickets/TicketBadge.svelte) +- [`TicketLinkInput.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/lib/components/tickets/TicketLinkInput.svelte) +- [`SessionTicketsSection.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/lib/components/tickets/SessionTicketsSection.svelte) +- [`/tickets/+page.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/routes/tickets/+page.svelte) +- [`/tickets/[provider]/[external_key]/+page.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte) + +Verbal description of each surface today: + +- **``** — rounded-full chip, ~20px high, monospace key + optional title (truncated at ~18ch), tiny external-link icon, optional X to unlink. Provider color is the only differentiator. +- **``** — used inside table cells; small provider label chip + monospace key + en-dash + title. No background. +- **``** — header on detail page; provider label chip + monospace key + external-link icon + title on a second line + status on a third line. Sits in a `bg-subtle` card. +- **``** — single-row input with placeholder `Paste URL or ref (e.g. LINEAR-123, owner/repo#42)`, conditional provider dropdown to the right of the input (only when input is bare alphanumeric), violet "Link" submit button with `+` icon. Errors render below in `--error` color. +- **``** — section card with title row (icon + "TICKETS" + count), wrapped list of pills, then the input below. Sits above `` on session pages. +- **`/tickets` index** — full-width table inside max-w-6xl container. Columns: Ticket / Title / Status / Sessions (right-aligned) / Last linked. +- **`/tickets/[provider]/[external_key]`** — back link, card-variant badge, metadata line, then "LINKED SESSIONS (N)" heading and a list of cards (one per linked session). + +## 10. Deliverables format I want back + +For each question in §7: + +1. **2–3 rendered variants** (HTML in an Artifact is fine) +2. **One-paragraph rationale per variant** — what it optimizes for, what it trades away +3. **Tailwind class strings** I can lift directly into the Svelte components (so I don't have to reverse-engineer your visuals) +4. **Tokens used** explicitly named from the §4 list — so I don't have to guess whether you meant `--bg-subtle` or `--bg-muted` +5. **Dark-mode parity** — at least one variant per question shown in dark mode, even if it's the same structure with token names swapped + +For cross-cutting decisions (provider language, micro-interaction system) please also produce a **system spec page** — a single artifact showing all three providers / all five input states side-by-side so I can implement them coherently. + +## 11. What I will return to you after implementing + +Per the §3 contract for "Implementation Diff": + +- Branch / PR URL with the changes +- Real screenshots: `/tickets` populated (1 row, 4 rows, 20+ rows), empty state, mobile breakpoint, session detail with section expanded +- Any place reality diverged from the mock and why (real data was longer, accessibility required a label, performance argued against an animation, etc.) + +Then I'll ask you for a critique (severity-ranked), polish, ship. + +## 12. Decision log + +This brief will be paired with `docs/design-briefs/2026-05-18-ticket-linking-ui-log.md` (created lazily as decisions land). Each loop appends one section with date, what was decided, and rationale. diff --git a/docs/design-briefs/2026-05-19-nav-header-redesign.md b/docs/design-briefs/2026-05-19-nav-header-redesign.md new file mode 100644 index 00000000..3a74235d --- /dev/null +++ b/docs/design-briefs/2026-05-19-nav-header-redesign.md @@ -0,0 +1,152 @@ +# Design Brief — Navigation Header Redesign + +**Date:** 2026-05-19 +**Audience:** Claude Design (`claude.ai/design`) +**Source repo:** [JayantDevkar/claude-code-karma](https://github.com/JayantDevkar/claude-code-karma) +**Branch context:** `feat/session-ticket-linking` — this brief is independent of that branch but reflects the current header state. + +--- + +## How to use this brief + +Paste this whole document into a fresh `claude.ai/design` conversation. It is self-contained. Return 2–3 rendered variants per numbered question in §7, with rationale, Tailwind classes I can lift directly, and dark-mode parity. + +--- + +## 1. Problem in one sentence + +The top navigation has grown from 7 → 12 link items as features shipped, and at typical viewport widths it now reads as a wall of densely-packed labels with no visual rhythm or grouping — the user has to *scan a sentence* to find a section. + +## 2. Current state (the data behind the screenshot) + +12 nav links in order, plus a Settings cog on the right: + +`Projects · Sessions · Tickets · Plans · Agents · Skills · Commands · Tools · Hooks · Plugins · Analytics · Archived` + +All items are visually identical: same font weight, same color, same spacing. The only differentiation is the active-route highlighting (the current page gets `text-[var(--text-primary)]`; everything else is `text-[var(--text-muted)]`). + +At 1440px viewport, the row spans roughly **800px** of horizontal real estate between the logo and the settings cog — it dominates the page and pushes content below. At narrower viewports (~1100px) the items start to wrap or overflow. + +This is the home/dashboard route screenshot the user shared: + +``` +[logo] Claude Code Karma Projects Sessions Tickets Plans Agents Skills Commands Tools Hooks Plugins Analytics Archived [⚙] +``` + +## 3. Goals + +- **Visual rhythm** — group related items so the eye lands instead of scans +- **Lower visual weight** — the nav should not feel like the protagonist of every page +- **Scale forward** — the next 3–5 items we ship shouldn't break the layout +- **Discoverability** — power users need fast access; new users need to know what's available +- **Keyboard navigability** — there's already a command palette (`⌘K`); the new nav must complement it, not duplicate it + +## 4. Constraints (must honor) + +- **Framework:** SvelteKit 2 + Svelte 5 runes; Tailwind 4 +- **No new dependencies** — `bits-ui`, `lucide-svelte`, `date-fns` are already in. Adding to `package.json` is a no. +- **Existing tokens** in `frontend/src/app.css`: + - `--bg-base / --bg-subtle / --bg-muted` + - `--text-primary / --text-secondary / --text-muted / --text-faint` + - `--border / --border-subtle / --border-hover` + - `--accent` (violet `#7c3aed` light, `#a78bfa` dark) + - `--shadow-sm / --shadow-md / --shadow-lg` + - `--nav-{blue,green,orange,purple,gray,red,yellow,teal,violet,indigo,amber}` and `-subtle` companions (each section has a "brand color" used on the homepage navigation cards — already shipped, see [`frontend/src/lib/components/NavigationCard.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/lib/components/NavigationCard.svelte)) +- **WCAG 2.1 AA** contrast required; visible focus rings; keyboard nav must work +- **Mobile**: there is already a mobile hamburger drawer below `md` breakpoint — your redesign needs a paired mobile treatment +- **Active-state**: the current implementation uses `aria-current="page"` plus a color change. Whatever you propose should preserve that semantic + +## 5. The 12 items, semantically grouped (my reading — feel free to challenge) + +| Group | Items | What they do | +|---|---|---| +| **Work** | Projects · Sessions · Tickets · Plans | The actual things the user is tracking. Tickets is the newest. | +| **Knowledge** | Agents · Skills · Commands | The reusable assets that get invoked inside sessions. | +| **Infra** | Tools · Hooks · Plugins | Plumbing — MCP servers, hook scripts, plugins. Power-user surfaces. | +| **Meta** | Analytics · Archived | Historical / cross-cutting views. Low traffic. | + +If you accept this grouping (4 groups of 3, with one outlier "Tickets" added recently), the design becomes a question of *how to express groups visually* rather than "shrink everything." + +## 6. Current code reference + +The header lives at: +[`frontend/src/lib/components/Header.svelte`](https://github.com/JayantDevkar/claude-code-karma/blob/feat/session-ticket-linking/frontend/src/lib/components/Header.svelte) + +Worth reading both the desktop nav (lines ~77–169) and the mobile drawer (lines ~203+) — your design needs both. + +## 7. Open questions — what we want design's opinion on + +Return 2–3 variants per question. For at least one variant per question, show the dark-mode treatment too. + +### 7.1 — Overall structural approach + +Which of these directions does the right thing for this app? Or propose a 4th. + +- **A. Group with dividers.** Same 12 items, but visually clustered with thin `var(--border-subtle)` separators between the four groups. No interaction change. +- **B. Primary nav + overflow menu.** Show 4–6 "primary" items (Projects, Sessions, Tickets, Analytics, maybe Plans). Move the rest behind a "More ▾" dropdown. +- **C. Icon-first compact nav.** Replace text labels with icons (each item already has a homepage `NavigationCard` icon — Bot, Wrench, Webhook, Puzzle, etc.). Tooltips on hover. Active item shows its label. Roughly halves the horizontal footprint. +- **D. Sidebar.** Move nav into a collapsible left rail. Tradeoff: gives up horizontal space, gains vertical breathing room and group-headers. + +### 7.2 — Active-state treatment + +Today: the active route is `text-[var(--text-primary)]` while inactive is `text-[var(--text-muted)]`. That's a *very* subtle differentiator — easy to miss. Should the active state be louder (underline / pill background / accent bar)? Show 2 variants. + +### 7.3 — Group differentiation + +If we go with §7.1 A (or a variant), how do groups visually separate? Options: +- Thin vertical dividers +- A small gap between groups (whitespace alone) +- Tiny group labels above each cluster (`WORK` `KNOWLEDGE` `INFRA` `META`) in `text-faint` 9px caps +- Color-tinting within groups (Work uses `--nav-blue`-family tones, etc.) + +### 7.4 — The command palette relationship + +There's already a `⌘K` palette (see the footer bar: "Command Menu ⌘ K"). For power users, the nav doesn't need to surface every section — they'll palette. For first-time users, the nav IS the discovery surface. Design needs to decide: is the new nav optimized for discovery or for power-user speed? Or both via different affordances? + +### 7.5 — Where does Settings go? + +Currently the cog sits on the right, separated from the nav. Should it stay? Move into the nav as one of the items? Get a stronger separation (vertical divider before it)? + +### 7.6 — Mobile + +The current mobile drawer (lines ~203+ of `Header.svelte`) is a vertical stack of the same 12 items. Should the mobile drawer: +- Mirror the desktop grouping exactly? +- Use a different organization (e.g., search-first, recent-sections-first)? +- Compress into icon grid? + +### 7.7 — Brand color expression + +Each section already has a brand color shipped on the homepage's `NavigationCard` (Projects=blue, Sessions=teal, Tickets=amber, etc.). Should that color show up in the nav header at all? Where? On the icon background, the underline, the active-state, nothing? + +## 8. Non-goals (don't redesign these) + +- The logo on the left — stays +- The `⌘K` command palette — stays as-is +- The routes themselves — `/projects` `/sessions` etc. don't change +- The page-level `` (just shipped, has icon-on-left + breadcrumb + subtitle) — that's a separate surface +- The homepage `NavigationCard` grid — that surface has its own treatment and is fine + +## 9. What I want back from this design pass + +For each question in §7: + +1. **2–3 rendered variants** (HTML in Artifacts is fine; React if you prefer) +2. **One-paragraph rationale per variant** — what it optimizes for, what it trades +3. **Tailwind class strings** I can lift directly into `Header.svelte` +4. **Tokens used** explicitly named from §4 list +5. **Dark-mode parity** — at least one variant per question rendered in dark + +For §7.1 (the big one), please also produce a **system spec page** showing the chosen direction on three viewports (mobile drawer, ~1024px tablet, 1440px desktop) so I can see how it scales together. + +## 10. What I'll return to you after implementing + +Per the workflow established for the ticket-linking feature: + +- Branch URL with the changes +- Live screenshots: home, /projects, /sessions, /tickets — to verify the active-state and group-visibility on different routes +- Any place reality diverged from the mock and why +- Then a critique-and-polish round before merge + +## 11. Decision log + +Decisions land in `docs/design-briefs/2026-05-19-nav-header-redesign-log.md` (paired companion). Each iteration appends one section with date, what was decided, and rationale. diff --git a/docs/design-briefs/2026-05-19-tickets-kanban.md b/docs/design-briefs/2026-05-19-tickets-kanban.md new file mode 100644 index 00000000..dd109d7b --- /dev/null +++ b/docs/design-briefs/2026-05-19-tickets-kanban.md @@ -0,0 +1,249 @@ +# Design Brief — `/tickets` Kanban Board + +**Date:** 2026-05-19 +**Branch:** `feat/session-ticket-linking` (post-v0.2.0) +**Builds on:** [Ticket Linking UI brief, 2026-05-18](./2026-05-18-ticket-linking-ui.md) +**Audience:** Claude Design (`claude.ai/design`) + +--- + +## How to use this brief + +Paste this entire document into a fresh `claude.ai/design` conversation. It is the only context you need. When you reply, please return **two or three rendered variants** (HTML or Svelte/Tailwind in Artifacts) for each numbered question in §8, with rationale and class strings I can lift directly into `frontend/src/routes/tickets/+page.svelte`. + +--- + +## 1. Problem in one sentence + +The `/tickets` page is a single dense table sorted by `last_linked_at` — fine for "what did I touch today?" but terrible for **"what's the state of my work?"**; we want to add a kanban-shaped *view* (read-only, board lives alongside the existing table behind a toggle) that surfaces ticket status at a glance without pretending karma can write back to Linear/Jira/GitHub. + +## 2. Goals + +- A **status-grouped board view** that is honestly read-only — drag affordances absent, "you're inspecting your work, not managing it" voice +- A **board ↔ table toggle** so power users with many tickets keep the scannable table +- A **canonical 5-column taxonomy** that maps cleanly onto provider-specific vocabularies (already half-implemented in `normalizeStatus()`) +- A **ticket card** that survives at any density — 4 tickets total (the typical user) and 50+ tickets (heavy users) both look intentional +- **Aesthetic continuity** with the existing terminal-flavored `/tickets` design (`$ tickets [N linked]` headers, mono, dashed borders) — or a deliberate alternative if the kanban metaphor benefits from a softer voice + +## 3. Non-goals + +- **No drag-and-drop.** Karma never writes to providers. Cards can be clicked through, not moved. +- **No new icon library, no new CSS framework, no new fonts.** Tailwind 4 + lucide-svelte only. +- **No backend changes.** All grouping happens client-side from the existing `GET /tickets` response. +- **No new status taxonomy.** Reuse the `StatusKey` already in `ticket-helpers.ts`. + +## 4. In-scope surfaces + +| Surface | File | Today | +|---|---|---| +| `/tickets` index page | `frontend/src/routes/tickets/+page.svelte` | Provider segmented filter + search + table | +| `/tickets` server load | `frontend/src/routes/tickets/+page.server.ts` | Reads `q`, `provider`, `project` query params | +| New: `` component | `frontend/src/lib/components/tickets/TicketsBoard.svelte` | TO CREATE | +| New: `` component | `frontend/src/lib/components/tickets/TicketCard.svelte` | TO CREATE — extracted from existing table-row markup | +| `` | `frontend/src/lib/components/tickets/TicketBadge.svelte` | Reused as-is for provider badges | +| Helpers | `frontend/src/lib/ticket-helpers.ts` | Reuses `normalizeStatus`, `statusColorVar`, `PROVIDER_META`, `formatRelative` | +| Project Tickets tab | `frontend/src/lib/components/tickets/ProjectTicketsTab.svelte` | OUT OF SCOPE this brief — table only. Possible future application. | + +## 5. Constraints (must honor) + +- **Framework:** SvelteKit 2 + Svelte 5 runes (`$state`, `$derived`, `$props`) +- **Styling:** Tailwind CSS 4 utilities only +- **Icons:** lucide-svelte (pick from existing set) +- **Read-only:** No drag, no in-place edit, no "create ticket" affordance +- **URL state:** view toggle persists via `?view=board` (kanban) or absence/`?view=table` (table, current default) +- **Design tokens** already defined in `frontend/src/app.css`: + + ```css + /* Backgrounds */ + --bg-base /* canvas */ + --bg-subtle /* cards, sections */ + --bg-muted /* nested inputs, table headers */ + --accent-subtle /* hover, active filter chips */ + + /* Text */ + --text-primary + --text-secondary + --text-muted + --text-faint /* timestamps */ + + /* Border */ + --border + --border-subtle + + /* Status (semantic, already wired) */ + --status-todo + --status-active + --status-review + --status-done + --status-closed + ``` + +- **Existing status keys** in `ticket-helpers.ts` (re-use verbatim): + + ```ts + type StatusKey = 'todo' | 'active' | 'review' | 'done' | 'closed' | 'unknown'; + ``` + +## 6. Pre-resolved decisions (do NOT re-litigate) + +| Q | Decision | +|---|---| +| Replace the table or add a toggle? | **Toggle.** Segmented control top-right. URL state `?view=board`. Table is default to preserve existing bookmark behavior. | +| Status taxonomy | **5 canonical columns** matching existing `StatusKey`: `todo` · `active` · `review` · `done` · `closed`. Tickets with `unknown` status (no status data yet) go into a 6th implicit column called **Unsorted** that auto-hides when empty. | +| Drag-and-drop? | **No.** Karma is read-only. The voice ("inspect", "audit", "trace") should reinforce that. | +| Empty-column behavior | **Show empty columns by default** so the taxonomy is visible. Provide a "Hide empty" affordance (toggle / chip) that persists in localStorage. | +| Counts in column headers | **Yes.** Every column header shows `[N]` even when 0. | +| Provider mixing within columns | **Yes.** A `done` column contains Linear-Done + Jira-Done + GitHub-Closed cards mixed, sorted by `last_linked_at`. | + +## 7. Three direction options + +The kanban metaphor is generic; your three options differ in **voice**, not in functionality. Pick one to lead with (variants on it are welcome). The other two are sketched for context. + +### Direction A — Terminal Kanban (recommended starting point) + +Matches the existing `/tickets` page aesthetic. Columns are presented as labeled groups in the terminal output of a fictional `karma tickets --group-by status` command. + +``` +┌─ $ tickets --group-by status ─────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ todo · 3 active · 1 review · 0 done · 5 closed · 2 │ +│ ───────── ────────── ────────── ──────── ───────── │ +│ ▸ LIN AB-9 ▸ GH foo/bar#42 ▸ LIN DE-246 Backlog→Done ▸ GH foo/bar#99 │ +│ Fix login Refactor auth Airflow Slack Bot Won't fix │ +│ 2 sess · 2h 8 sess · live 2 sess · 4 projects · 3h 1 sess · 1d │ +│ │ +│ ▸ JIRA PROJ-42 ▸ GH foo/bar#7 merged │ +│ Migrate DB Provider colors │ +│ 1 sess · 5h 1 sess · 6h │ +│ │ +│ ▸ LIN AB-10 ▸ ... │ +│ … │ +└───────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +**Visual rules:** +- Mono-spaced section headers (`todo · 3`) in `var(--text-muted)`, underlined with `─` to evoke `tabulate` +- Cards are flat divs with `border-l-2` colored by `--status-{key}` — no card shadows +- Column width: fixed `min-w-[280px]`, horizontal scroll if needed on narrow viewports +- Vertical lane separators: faint `border-l border-dashed` +- No background fill on columns — let `--bg-base` show through +- Card hover: `bg-[var(--accent-subtle)]` only; no transform/scale +- Live session indicator (when applicable): a small green pulsing dot in the right of the card row + +### Direction B — Editorial Inbox + +Departs from the terminal voice. Cards become more "blog-card" — bigger title, generous whitespace, status as a small uppercase tag in the corner. Reads like Linear's own board view but quieter. + +``` +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ TODO 3 │ │ ACTIVE 1 │ +│ │ │ │ +│ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │ +│ │ Fix login bug │ │ │ │ Refactor auth middleware │ │ +│ │ LINEAR · AB-9 │ │ │ │ GITHUB · foo/bar#42 │ │ +│ │ 2 sessions · 2h ago │ │ │ │ 8 sessions · live now │ │ +│ └───────────────────────────┘ │ │ └───────────────────────────┘ │ +│ ┌───────────────────────────┐ │ │ │ +│ │ Migrate DB to Postgres │ │ │ │ +│ │ JIRA · PROJ-42 │ │ │ │ +│ │ 1 session · 5h ago │ │ │ │ +│ └───────────────────────────┘ │ │ │ +└─────────────────────────────────┘ └─────────────────────────────────┘ +``` + +**Trade-off:** clearer at low density, but breaks the existing aesthetic. Adds visual weight that's already heavy from the project Tickets tab. + +### Direction C — Status Strip + Persistent Table + +Hybrid. The table stays — but above it sits a horizontal strip with 5 cells showing canonical-status counts and a top-3 list per status. Click a cell → filters the table below. + +``` +┌── todo ─── 3 ─┐ ┌─ active ── 1 ─┐ ┌─ review ─ 0 ─┐ ┌── done ── 5 ─┐ ┌─ closed ─ 2 ─┐ +│ AB-9 Fix lo… │ │ foo/bar#42 │ │ │ │ DE-246 Airfl…│ │ foo/bar#99 │ +│ PROJ-42 Migr…│ │ │ │ │ │ foo/bar#7 │ │ PROJ-9 Won't…│ +│ AB-10 … │ │ │ │ │ │ AB-3 … │ │ │ +└──────────────┘ └───────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ + +[same table as today, optionally pre-filtered by clicked strip cell] +``` + +**Trade-off:** ~zero new design surface, but doesn't really feel like a kanban. Useful as a fallback if the toggle is over-engineering. + +## 8. Variant questions for Claude Design + +For each, return 2–3 alternatives I can lift directly into Svelte/Tailwind. + +### Q1 — Column header treatment + +Three variants for the `todo · 3` header in Direction A. Goals: scannable, on-aesthetic, communicates status semantics without color overload. + +### Q2 — Card row + +Variants for a single ticket card. Required content: provider badge · external_key (mono) · title (1-2 lines, truncated) · session_count + last_linked relative time. Optional: project chip(s) for cross-project tickets (we just shipped this — DE-246 spans 2 projects on the dashboard right now). Status dot is implicit via the column it lives in. + +### Q3 — The toggle control + +Where does `[Board] [Table]` live? Top-right of `PageHeader` actions? Inline with the search bar? Separate row? Three options ranked by discoverability. + +### Q4 — Live-session affordance + +When a ticket has a live session (currently active), where does the indicator go on the card? A green dot is the obvious answer but feels noisy if every Active ticket has one. Variants for "discreet" vs "loud". + +### Q5 — Empty-column placeholder + +In Direction A's terminal voice, what does an empty `review · 0` column look like? Plain empty space feels cold. A subtle hint (e.g., `# no tickets in review`) might work but risks looking like comments-as-content. + +### Q6 — Cross-provider mixing aesthetic + +A `done` column might show a mix of Linear-Done (green-blue), Jira-Done (typically green), GitHub-Closed/Merged (purple/red). The provider badge already encodes color; the card border-left is the canonical status color. Variants for "do they fight or harmonize?" + +### Q7 — Density mode + +For users with 50+ tickets in a single column, do we collapse to a more compact one-line view, paginate, or virtualize? The existing table handles density trivially; the board does not. + +## 9. Out-of-scope follow-ups (mention if you see opportunities, don't design) + +- Same kanban applied to `ProjectTicketsTab` — likely future iteration +- Per-column sort (currently fixed: `last_linked_at` desc) — let users pick? probably yes, post-v1 +- Saved board views / filters — out of scope +- Drag to multi-select for bulk operations (unlink, copy URLs) — out of scope + +## 10. Implementation notes (for the agent, not Claude Design) + +When implementation lands: + +```ts +// 1. URL state in +page.svelte (Svelte 5 runes) +let view = $derived(($page.url.searchParams.get('view') as 'board' | 'table') ?? 'table'); +function setView(v: 'board' | 'table') { + const sp = new URLSearchParams($page.url.searchParams); + if (v === 'table') sp.delete('view'); else sp.set('view', v); + goto(`/tickets?${sp.toString()}`); +} + +// 2. Group tickets client-side from data.tickets +const COLUMN_ORDER = ['todo','active','review','done','closed'] as const; +let grouped = $derived.by(() => { + const map: Record = { + todo:[], active:[], review:[], done:[], closed:[], unknown:[] + }; + for (const t of data.tickets) { + const k = normalizeStatus(t.status).key; + map[k].push(t); + } + return map; +}); +``` + +No backend changes. No new endpoints. No new fields on the API response. + +## 11. References / inspiration + +- Linear's "Board" view (`linear.app/.../board`) — the cleanest read-write kanban +- Height.app's "List" view — group-by-status without true kanban affordance +- GitHub Projects (the new beta) — read-mostly status board with high information density +- The existing karma `/tickets` page — the design we're extending, not replacing + +--- + +**Send me back:** 2–3 rendered variants for each of Q1–Q7, with Tailwind classes I can lift. Lead with Direction A. If Direction B or C is clearly better for any specific question, say so and show. diff --git a/docs/design-briefs/iterations/2026-05-18/Q1-provider-language-4-directions.png b/docs/design-briefs/iterations/2026-05-18/Q1-provider-language-4-directions.png new file mode 100644 index 00000000..f4b4daf2 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q1-provider-language-4-directions.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q2-empty-state-3-variants.png b/docs/design-briefs/iterations/2026-05-18/Q2-empty-state-3-variants.png new file mode 100644 index 00000000..2a013272 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q2-empty-state-3-variants.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q3-detail-title-first.png b/docs/design-briefs/iterations/2026-05-18/Q3-detail-title-first.png new file mode 100644 index 00000000..e6992569 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q3-detail-title-first.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q4-link-input-state-spec.png b/docs/design-briefs/iterations/2026-05-18/Q4-link-input-state-spec.png new file mode 100644 index 00000000..12aa3a55 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q4-link-input-state-spec.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q4b-link-input-live-demo.png b/docs/design-briefs/iterations/2026-05-18/Q4b-link-input-live-demo.png new file mode 100644 index 00000000..ed5ef36e Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q4b-link-input-live-demo.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q5-section-placement-3-variants.png b/docs/design-briefs/iterations/2026-05-18/Q5-section-placement-3-variants.png new file mode 100644 index 00000000..60ed72cb Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q5-section-placement-3-variants.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q6-unlink-destructive-treatment.png b/docs/design-briefs/iterations/2026-05-18/Q6-unlink-destructive-treatment.png new file mode 100644 index 00000000..fda7e3b4 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q6-unlink-destructive-treatment.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q9a-project-tickets-touched-3-placements.png b/docs/design-briefs/iterations/2026-05-18/Q9a-project-tickets-touched-3-placements.png new file mode 100644 index 00000000..3c3cd84c Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q9a-project-tickets-touched-3-placements.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants-v2.png b/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants-v2.png new file mode 100644 index 00000000..fb56580e Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants-v2.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants.png b/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants.png new file mode 100644 index 00000000..5b65f850 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/Q9b-ticket-sessions-grouped-3-variants.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/critique-notes.png b/docs/design-briefs/iterations/2026-05-18/critique-notes.png new file mode 100644 index 00000000..1767d960 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/critique-notes.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/ticket-ui-review.html b/docs/design-briefs/iterations/2026-05-18/ticket-ui-review.html new file mode 100644 index 00000000..2efbc12e --- /dev/null +++ b/docs/design-briefs/iterations/2026-05-18/ticket-ui-review.html @@ -0,0 +1,72 @@ + + + + + + Claude Karma — Ticket UI Review + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v1.png b/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v1.png new file mode 100644 index 00000000..1c24f56f Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v1.png differ diff --git a/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v2.png b/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v2.png new file mode 100644 index 00000000..1c24f56f Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-18/tickets-populated-interactive-v2.png differ diff --git a/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.html b/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.html new file mode 100644 index 00000000..4c565c0a --- /dev/null +++ b/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.html @@ -0,0 +1,327 @@ +C _ Icon-first compact
Claude Code Karma
Karma · Sessions
1440px viewport · hover on Hooks

Sessions

All sessions across projects

Optimises for
Roughly halving the horizontal footprint. The icons already exist (each section's NavigationCard.svelte ships one). Active item expands to icon + label inside a brand-tinted pill; everything else is icon-only with tooltip on hover.
Tradeoff
Steeper learning curve. New users will need to mouse over every glyph to learn the system. Cable / Webhook / Puzzle in particular are not universally recognisable. Pair with a one-time hover-tour or the first 1–2 weeks showing labels too.
Tokens used
--nav-{color}--nav-{color}-subtle--text-muted--bg-base (tooltip)--shadow-md (tooltip)
Tailwind (drop into Header.svelte)
<a href="/hooks" aria-label="Hooks" title="Hooks"
+   class="relative inline-flex items-center gap-1.5 p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+   class:bg-[var(--nav-amber-subtle)]={active}
+   class:text-[var(--nav-amber)]={active}
+   class:px-2.5={active} class:py-1.5={active}>
+  <Webhook class="size-4" />
+  {#if active}<span class="text-[12.5px] font-medium text-[var(--text-primary)]">Hooks</span>{/if}
+</a>
\ No newline at end of file diff --git a/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.png b/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.png new file mode 100644 index 00000000..1b80ded4 Binary files /dev/null and b/docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.png differ diff --git a/docs/features/2026-03-19-agent-walkie-talkie.md b/docs/features/2026-03-19-agent-walkie-talkie.md new file mode 100644 index 00000000..76b70191 --- /dev/null +++ b/docs/features/2026-03-19-agent-walkie-talkie.md @@ -0,0 +1,828 @@ +# Feature Definition: Agent Walkie-Talkie + +## Section 1: Scope & Sub-Features + +### Purpose +Enable two Claude Code sessions on different physical machines to coordinate actions during multi-device testing. A developer sits at one machine (commander) and orchestrates a test scenario; the other machine (responder) runs autonomously, executing requested actions and reporting results — all communicated via a temporary git branch. + +### Problem Today +Testing sync v4 requires 2 machines (leader + member). The developer manually: +1. Runs a Claude session on Machine A → does an action +2. Switches to Machine B → runs another session for the next step +3. When B hits a bug → fix, commit, push +4. Switch back to A → pull, continue +5. The git log becomes the informal communication channel + +This is slow, error-prone, and loses context between sessions. + +### The Gap We Fill + +No existing tool combines all 5 properties needed for multi-device testing with AI agents: + +| Property | What It Means | Who Gets Close | Why They Fall Short | +|----------|--------------|----------------|-------------------| +| **Cross-physical-machine** | Two independent machines coordinate | AutoGen (gRPC), Walkie (P2P) | AutoGen needs a broker server; Walkie needs live P2P connection | +| **Zero new infrastructure** | Works with what developers already have | Walkie (P2P daemon) | Walkie requires a background daemon; claude-code-by-agents requires open HTTP ports | +| **Async & durable** | Messages survive crashes, full audit trail | mcp_agent_mail (git+MCP) | Requires a running MCP server alongside git | +| **LLM-native** | Natural language instructions, not typed functions | CrewAI, AutoGen | Single-machine only; no physical machine isolation | +| **Claude Code native** | Leverages existing skills, tools, worktrees | Agent Teams (Anthropic) | Local filesystem only — inboxes don't cross machines | + +**Closest competitor**: [Walkie](https://github.com/vikasprogrammer/walkie) (vikasprogrammer) — P2P encrypted agent chat with a Claude Code skill. But it's real-time/ephemeral (no persistence, no offline support, no audit trail, no structured test scenarios). We are async/durable/structured. + +**Structural analog**: Claude Code's own Agent Teams (Feb 2026) uses JSON inbox files on disk (`~/.claude//inboxes/.json`). Our product is architecturally identical — except the inboxes are git-committed, making them cross-machine. + +### Product Layer Model + +We build a thin protocol layer on top of two powerful platforms that already exist: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SCENARIO LAYER (we build) │ +│ YAML test definitions, step sequencing, variable │ +│ resolution, step tracking, pass/fail evaluation │ +│ → walkie/scenario.py │ +├─────────────────────────────────────────────────────────────┤ +│ PROTOCOL LAYER (we build) │ +│ Channel lifecycle (create/join/stop/cleanup), │ +│ JSON message format, sequential IDs, message tracking, │ +│ intervention/guidance flow │ +│ → walkie/channel.py, walkie/messages.py │ +├─────────────────────────────────────────────────────────────┤ +│ SKILL LAYER (we build) │ +│ Claude Code skills that teach the LLM the protocol: │ +│ /walkie start, /walkie join, /walkie run, /walkie stop │ +│ Skills are prompts, not code — they instruct the LLM │ +├─────────────────────────────────────────────────────────────┤ +│ CLAUDE CODE (we leverage — not ours) │ +│ LLM interpretation, Bash/Read/Write/Edit tools, │ +│ API calls via curl, DB queries via sqlite3, │ +│ error handling, improvisation, natural language │ +├─────────────────────────────────────────────────────────────┤ +│ GIT (we leverage — not ours) │ +│ Branch isolation (walkie/{name}), push/pull transport, │ +│ commit history as audit log, SSH/HTTPS auth, │ +│ NAT traversal, offline message queuing │ +└─────────────────────────────────────────────────────────────┘ +``` + +**What we build**: 3 thin Python modules + 6 Claude Code skills +**What we leverage**: Claude Code (executor) + Git (transport + auth + persistence) +**What we don't build**: execution engine, transport layer, auth system, message broker + +### Sub-Features + +1. **Channel Setup** — Create a temporary `walkie/{test-name}` branch for communication + - Triggered by: Commander runs `/walkie start --name sync-test-001` + - Depends on: Git repo with remote, both machines can push/pull + +2. **Scenario Definition** — Define a multi-step test scenario in YAML with actors, actions, and expected outcomes + - Triggered by: Commander creates or references a scenario file + - Depends on: Channel setup + +3. **Message Exchange** — Write/read JSON message files via git push/pull + - Triggered by: Commander sends action, responder detects via git pull loop + - Depends on: Channel setup + +4. **Action Execution** — Responder parses messages and executes API calls, CLI commands, DB queries, Syncthing checks + - Triggered by: New message detected in `.walkie/messages/` + - Depends on: Message exchange, local API server running + +5. **State Capture** — Snapshot relevant DB tables and system state on request + - Triggered by: Commander requests state, or scenario step requires it + - Depends on: Action execution + +6. **Intervention Handling** — Responder reports back when it hits an error or needs clarification + - Triggered by: Action fails, unexpected state, or ambiguous instruction + - Depends on: Message exchange + +7. **Channel Cleanup** — Delete the temporary branch and `.walkie/` directory after test completes + - Triggered by: Commander runs `/walkie stop` or scenario completes + - Depends on: Channel setup + +### Not In Scope +- Real-time communication (sub-second latency) +- More than 2 machines (v1 is pair-only) +- Syncthing as transport (can't test Syncthing with Syncthing) +- Automatic responder startup (manual for MVP) +- Persistent message history across tests (branch is deleted) +- UI/frontend for walkie-talkie (CLI only) + +--- + +## Section 2: Actors & Roles + +| Actor | Type | Capabilities | Restrictions | +|-------|------|-------------|-------------| +| **Developer** | human | Starts walkie session on commander, writes scenario, monitors progress, intervenes when responder is stuck | Must be present on commander machine | +| **Commander Session** | Claude Code session (Machine A) | Send action messages, receive responses, run local actions, orchestrate scenario flow, display results to developer | Cannot directly execute on Machine B | +| **Responder Session** | Claude Code session (Machine B) — **LLM-driven** | Poll for messages, interpret action specs, execute locally using judgement, capture state, report results, request intervention. May improvise when action specs are ambiguous or when encountering unexpected state. | Cannot initiate — only reacts to messages. Cannot modify the scenario. Must not invent steps or skip steps. | +| **Git Remote** | system (GitHub) | Store and relay messages between machines via push/pull | 5-10s latency per round trip | +| **Local API Server** | system (per machine) | Execute sync endpoints, serve DB state | Must be running on both machines | +| **Syncthing** | external (per machine) | Sync files between machines (the system under test) | Not used as walkie transport | + +**Critical rules:** +- Commander is the **orchestrator** — it drives the test flow +- Responder is **autonomous but obedient** — executes what it's told, reports back, asks for help when stuck +- Both sessions are **LLM-driven Claude Code sessions** — they interpret natural language instructions, use Claude Code's native tools (Bash, Read, Write, etc.) to execute, and can improvise when things go wrong +- Communication is **async** — 5-10s git push/pull latency between each message +- The developer reads results on the commander side — they don't switch machines +- **We don't build an executor** — Claude Code already knows how to make API calls, run CLI commands, query databases, and read files. Our product is the *protocol* (message format, channel lifecycle, scenario definition), not the execution engine. + +--- + +## Section 3: Vocabulary + +| Term | Definition | NOT the same as | +|------|-----------|-----------------| +| **channel** | A temporary git branch (`walkie/{name}`) containing `.walkie/` directory for message exchange | chat room, IRC channel — this is git-based, async | +| **commander** | The Claude Code session the developer is sitting at. Drives the scenario. | leader (sync concept) — commander is about the test session, not sync role | +| **responder** | The autonomous Claude Code session on the remote machine. Executes actions. | member (sync concept) — responder is about the test session | +| **message** | A JSON file in `.walkie/messages/` representing one action or response | git commit — a commit may contain multiple messages | +| **scenario** | A YAML file defining a sequence of steps with actors, actions, and expected outcomes | test case — a scenario is a coordinated multi-machine test | +| **step** | One action in a scenario, assigned to an actor (commander or responder) | message — a step may generate multiple messages (action + response) | +| **intervention** | Responder reports back that it needs human/commander guidance | error — not all interventions are errors (could be ambiguous instructions) | +| **action** | A natural language instruction with a structured category hint. The LLM interprets and executes using Claude Code's native tools. | message — action is what to DO, message is how it's communicated | +| **category** | A hint classifying what kind of action to perform (api, cli, db_query, etc.). Guides the LLM, not a rigid execution spec. | action type — category is metadata, not a dispatch key | +| **state snapshot** | A capture of relevant DB tables, Syncthing config, or filesystem state at a point in time | log — snapshot is structured data, not narrative | +| **round trip** | Commander sends action → responder executes → responder sends response → commander receives. ~10-20s including 2 git push/pull cycles. | message — a round trip is 2 messages | + +--- + +## Section 4: State Models + +### State Model: Channel + +| State | Meaning | +|-------|---------| +| CREATED | Branch created, `.walkie/channel.json` initialized, not yet started | +| ACTIVE | Both commander and responder are connected, scenario running | +| WAITING | Responder requested intervention, commander hasn't responded yet | +| COMPLETED | Scenario finished (all steps done or explicitly stopped) | +| CLEANED | Branch deleted, `.walkie/` removed | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| — | CREATED | Commander `/walkie start` | Create branch, `.walkie/` dir, `channel.json`, push | Yes | +| CREATED | ACTIVE | Responder detects channel and confirms | Update `channel.json` with responder info | Yes | +| ACTIVE | WAITING | Responder sends intervention message | Commander notified | Yes | +| WAITING | ACTIVE | Commander responds to intervention | Responder continues | Yes | +| ACTIVE | COMPLETED | Last scenario step finished or `/walkie stop` | Write completion summary | No | +| COMPLETED | CLEANED | Commander `/walkie cleanup` | Delete branch locally + remote | No | + +### State Model: Scenario Step + +| State | Meaning | +|-------|---------| +| PENDING | Step defined but not yet started | +| EXECUTING | Actor is executing the action | +| PASSED | Action completed, outcome matches expected | +| FAILED | Action completed, outcome doesn't match expected | +| BLOCKED | Action cannot proceed (waiting for intervention) | +| SKIPPED | Step was skipped (dependency failed or manually skipped) | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| PENDING | EXECUTING | Previous step completed, actor picks up next step | Write action message | Yes | +| EXECUTING | PASSED | Action result matches `expect` | Write response message with result | Yes | +| EXECUTING | FAILED | Action result doesn't match `expect` | Write response with actual vs expected | Yes | +| EXECUTING | BLOCKED | Action hits error or ambiguity | Write intervention request | Yes | +| BLOCKED | EXECUTING | Commander responds with guidance | Retry action | Yes | +| PENDING | SKIPPED | Dependency step failed and `on_fail: skip_rest` | Log skip reason | Yes | + +### State Model: Message + +| State | Meaning | +|-------|---------| +| WRITTEN | JSON file created in `.walkie/messages/` | +| COMMITTED | File committed to git | +| PUSHED | Commit pushed to remote | +| RECEIVED | Other party pulled and read the message | +| ACKNOWLEDGED | Other party sent a response referencing this message | + +Messages are immutable once written. No state transitions on the message itself — the lifecycle is implicit from the git log. + +**Critical questions answered:** +1. **Initiator state**: Commander stays ACTIVE. Sending a message doesn't change channel state. +2. **Late joiner**: Responder must join before scenario starts. No mid-scenario joining. +3. **Idempotency**: Messages have sequential IDs. Duplicate pulls don't re-process already-seen messages. +4. **Cross-system sync**: Messages propagate via `git push` → `git pull`. ~5-10s per hop. +5. **Cascade**: Channel COMPLETED → all remaining PENDING steps become SKIPPED. + +--- + +## Section 5: Workflows + +### Workflow 5.1: Start Walkie Session + +**Trigger**: Developer runs `/walkie start --name sync-test-001 --scenario scenarios/full-sync-lifecycle.yaml` +**Preconditions**: Git repo with remote, current branch is the branch to test + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Commander | Create branch `walkie/sync-test-001` off current branch | branch name | Branch exists | Append timestamp suffix | +| 2 | Commander | Create `.walkie/` directory structure | — | — | — | +| 3 | Commander | Write `channel.json` with commander info | member_tag, machine, timestamp | — | — | +| 4 | Commander | Copy scenario YAML to `.walkie/scenario.yaml` | scenario content | File not found | Error: specify valid scenario | +| 5 | Commander | Commit + push branch | — | Remote down | Fatal — can't proceed without remote | +| 6 | Commander | Display: "Channel ready. Start responder on Machine B with: `/walkie join walkie/sync-test-001`" | — | — | — | + +### Workflow 5.2: Responder Joins Channel + +**Trigger**: Developer manually runs `/walkie join walkie/sync-test-001` on Machine B +**Preconditions**: Branch exists on remote, Machine B has the repo + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Responder | `git fetch && git checkout walkie/sync-test-001` | branch name | Branch not found | Error: check branch name | +| 2 | Responder | Read `channel.json`, validate | commander info | Invalid format | Error: corrupted channel | +| 3 | Responder | Read `scenario.yaml`, parse all steps | scenario content | Parse error | Report to developer | +| 4 | Responder | Update `channel.json` with responder info AND local variables | member_tag, machine, pairing_code, device_id, timestamp | — | — | +| 5 | Responder | Commit + push confirmation (commit message: "walkie: responder joined with variables") | — | — | — | +| 6 | Responder | Start git pull loop (every 5s) | — | — | — | +| 7 | Responder | Enter autonomous mode: wait for first message | — | — | — | + +### Workflow 5.3: Scenario Execution (Choreography) + +**Trigger**: Both commander and responder connected, scenario loaded +**Preconditions**: Channel ACTIVE, commander has pulled responder's join commit (with published variables) + +**Synchronization**: No formal checkpoints needed. The message exchange pattern enforces ordering — the responder cannot execute step N until the commander sends it, and the commander won't send step N+1 until it receives the response for step N. Consecutive same-actor steps (e.g., commander steps 1→2→3) execute locally in sequence without message exchange. + +**Step timeout**: Each step has a default timeout of 60s (configurable per-step via `step_timeout` in YAML). If the commander receives no response within `step_timeout + 20s` (accounting for 2 git round trips), the step is marked BLOCKED and the developer is notified. + +``` +For each step in scenario: + 1. Resolve any ${{variables}} in the step definition + - If resolution fails → BLOCK, request intervention + 2. Determine actor (commander or responder) + 3. If actor is local → execute action locally + 4. If actor is remote → write action message, push, wait for response (with timeout) + 5. Read response → compare result with expected outcome + 6. Mark step PASSED/FAILED/BLOCKED + 7. If BLOCKED → wait for intervention + 8. If FAILED and on_fail=stop → stop scenario + 9. Continue to next step +``` + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Commander | Read next step from scenario | step definition | No more steps | Scenario complete | +| 2 | Commander | If step.actor == "commander": execute locally | action, expect | Action fails | Mark FAILED, check on_fail | +| 3 | Commander | If step.actor == "responder": write action message | action JSON | — | — | +| 4 | Commander | Commit + push | — | Push fails | Retry once | +| 5 | Responder | Pull, detect new message | message JSON | — | — | +| 6 | Responder | Parse action, execute locally | API/CLI/DB/Syncthing | Action fails | Write intervention message | +| 7 | Responder | Compare result with expected | result vs expect | Mismatch | Mark FAILED in response | +| 8 | Responder | Write response message + commit + push | result JSON | — | — | +| 9 | Commander | Pull, read response | response JSON | — | — | +| 10 | Commander | Display result to developer, mark step PASSED/FAILED | — | — | — | + +### Workflow 5.4: Intervention Handling + +**Trigger**: Responder encounters error, unexpected state, or ambiguous instruction + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Responder | Write intervention message (type=intervention) | error details, context, what it tried | — | — | +| 2 | Responder | Commit + push | — | — | — | +| 3 | Responder | Enter WAITING state (stop processing new steps) | — | — | — | +| 4 | Commander | Pull, detect intervention | intervention JSON | — | — | +| 5 | Commander | Display to developer: "Responder needs help: {details}" | — | — | — | +| 6 | Developer | Provides guidance to commander | free text | — | — | +| 7 | Commander | Write guidance message (type=guidance) | instructions | — | — | +| 8 | Commander | Commit + push | — | — | — | +| 9 | Responder | Pull, read guidance, resume execution | — | — | — | + +### Workflow 5.5: Commander UX (Developer View) + +**What the developer sees**: A step-by-step log in the terminal, similar to a test runner. Each step prints as it completes. + +``` +🟢 Step 1/12: Create team ........................... PASSED (342ms) +🟢 Step 2/12: Share project ......................... PASSED (215ms) +⏳ Step 3/12: Add responder as member ............... WAITING (sent to responder) +🟢 Step 3/12: Add responder as member ............... PASSED (1.2s + 12s transport) + → responder: {"member_tag": "jayant.mac-mini", "status": "added"} +⏳ Step 4/12: Run reconciliation .................... WAITING (sent to responder) +🔴 Step 6/12: Accept subscription ................... FAILED + → expected: status 200 actual: status 404 + → responder note: "Subscription not found. Metadata may not have synced yet." +🟡 Step 6/12: INTERVENTION REQUESTED + → "Should I run reconciliation again, or is there a bug?" + Developer: "Run reconciliation first, then retry" +⏳ Step 6/12: Retrying with guidance ... +🟢 Step 6/12: Accept subscription ................... PASSED (after retry) +``` + +The developer watches passively. They only type when: +- An intervention is requested (responder needs help) +- They want to stop (`/walkie stop`) + +No interactive confirmation per step — the scenario runs automatically. + +### Workflow 5.6: Recovery from Crash + +**Trigger**: Responder session crashes mid-step and is restarted via `/walkie join` + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Responder | Re-checkout walkie branch, pull latest | — | Branch gone | Channel was cleaned up, nothing to recover | +| 2 | Responder | Read `channel.json` state | channel state | COMPLETED | Session is over, exit | +| 3 | Responder | Scan `.walkie/messages/` for last message | sequence IDs | — | — | +| 4 | Responder | Determine: was the last action responded to? | action vs response messages | — | — | +| 5 | Responder | Write intervention message: "Responder restarted. Last known state: step {N}, {responded/not responded}. How should I proceed?" | crash context | — | — | +| 6 | Responder | Commit + push intervention | — | — | — | +| 7 | Commander | Display intervention to developer | — | — | — | +| 8 | Developer | Provides guidance: "retry step N" or "skip to step N+1" | free text | — | — | +| 9 | Commander | Write guidance, push | — | — | — | +| 10 | Responder | Resume based on guidance | — | — | — | + +**Why intervention over auto-retry**: Some steps are not idempotent (e.g., creating a team that already exists). The LLM responder doesn't know if the previous attempt partially succeeded. Asking the commander (who has the developer) is safer. + +--- + +## Section 6: Data Contracts + +### Channel File (`.walkie/channel.json`) +```json +{ + "name": "sync-test-001", + "created_at": "2026-03-19T10:00:00Z", + "base_branch": "worktree-syncthing-sync-design", + "state": "active", + "commander": { + "member_tag": "jayant.macbook-pro", + "machine": "Jayants-MacBook-Pro", + "joined_at": "2026-03-19T10:00:00Z" + }, + "responder": { + "member_tag": "jayant.mac-mini", + "machine": "Jayants-Mac-mini", + "pairing_code": "amF5YW50...", + "device_id": "DEV-MINI-ABC", + "joined_at": "2026-03-19T10:00:15Z" + }, + "scenario": "scenarios/full-sync-lifecycle.yaml", + "current_step": 3, + "steps_total": 12, + "steps_passed": 2, + "steps_failed": 0 +} +``` + +### Scenario File (`.walkie/scenario.yaml`) +```yaml +name: "Full Sync Lifecycle" +description: "Test complete flow: team creation → project sharing → subscription → session sync" +timeout: 600 # seconds for entire scenario +step_timeout: 60 # default per-step timeout in seconds (overridable per step) + +setup: + commander: + require: ["API server running on port 8000", "Syncthing running", "sync initialized"] + responder: + require: ["API server running on port 8000", "Syncthing running", "sync initialized"] + +steps: + - id: 1 + actor: commander + name: "Create team" + action: + type: api + method: POST + path: /sync/teams + body: { "name": "walkie-test" } + expect: + status: 201 + body_contains: { "name": "walkie-test" } + + - id: 2 + actor: commander + name: "Share project" + action: + type: api + method: POST + path: /sync/teams/walkie-test/projects + body: { "git_identity": "jayantdevkar/claude-code-karma", "encoded_name": "-Users-jayantdevkar-Documents-GitHub-claude-karma" } + expect: + status: 201 + + - id: 3 + actor: commander + name: "Add responder as member" + action: + type: api + method: POST + path: /sync/teams/walkie-test/members + body: { "pairing_code": "${{responder.pairing_code}}" } + expect: + status: 201 + + - id: 4 + actor: responder + name: "Run reconciliation to discover team" + action: + type: api + method: POST + path: /sync/reconcile + expect: + status: 200 + + - id: 5 + actor: responder + name: "Verify team discovered" + action: + type: api + method: GET + path: /sync/status + expect: + body_contains: { "teams": [{ "name": "walkie-test" }] } + + - id: 6 + actor: responder + name: "Accept subscription with direction BOTH" + action: + type: api + method: POST + path: /sync/subscriptions/walkie-test/jayantdevkar%2Fclaude-code-karma/accept + body: { "direction": "both" } + expect: + status: 200 + body_contains: { "status": "accepted", "direction": "both" } + + - id: 7 + actor: commander + name: "Run reconciliation to sync subscription status" + action: + type: api + method: POST + path: /sync/reconcile + expect: + status: 200 + + - id: 8 + actor: commander + name: "Verify responder's subscription synced" + action: + type: state_snapshot + tables: ["sync_subscriptions"] + filter: { "member_tag": "${{responder.member_tag}}" } + expect: + rows_contain: { "status": "accepted" } + + - id: 9 + actor: commander + name: "Package sessions" + action: + type: api + method: POST + path: /sync/package + expect: + status: 200 + + - id: 10 + actor: responder + name: "Wait for sessions to arrive" + action: + type: wait + condition: "inbox folder has manifest.json" + timeout: 120 + poll_interval: 10 + expect: + condition_met: true + + - id: 11 + actor: responder + name: "Verify received sessions" + action: + type: state_snapshot + tables: ["sessions"] + filter: { "source": "remote" } + expect: + row_count_gte: 1 + + - id: 12 + actor: commander + name: "Cleanup: dissolve team" + action: + type: api + method: DELETE + path: /sync/teams/walkie-test + expect: + status: 200 + +on_fail: stop +``` + +### Variable Resolution + +Variables use `${{namespace.key}}` syntax. They are resolved at **scenario load time** (after both parties have joined), not at step execution time. + +**Namespace:** + +| Namespace | Source | Available After | +|-----------|--------|----------------| +| `commander.*` | `channel.json → commander` object | Channel created | +| `responder.*` | `channel.json → responder` object | Responder joins | + +**Available variables:** + +| Variable | Example Value | +|----------|--------------| +| `${{commander.member_tag}}` | `jayant.macbook-pro` | +| `${{commander.machine}}` | `Jayants-MacBook-Pro` | +| `${{commander.device_id}}` | `DEV-MBP-XYZ` | +| `${{responder.member_tag}}` | `jayant.mac-mini` | +| `${{responder.pairing_code}}` | `amF5YW50...` | +| `${{responder.device_id}}` | `DEV-MINI-ABC` | + +**Resolution flow:** +1. Commander starts scenario → pulls latest `channel.json` (which now has responder's published variables) +2. Walk all steps, replace `${{...}}` tokens with values from `channel.json` +3. If any variable cannot be resolved → step is **BLOCKED**, intervention requested: `"Unresolved variable: {name}. Check channel.json — was it published during join?"` + +**Not supported in v1:** Step output references (e.g., `${{step.1.response.body.id}}`). Scenario values that depend on runtime results should be hardcoded or use natural language instructions ("use the team ID from the previous step"). + +### Message File (`.walkie/messages/{sequence}-{actor}.json`) + +Messages carry **natural language instructions** with **structured metadata** for categorization. The responder (an LLM-driven Claude Code session) interprets the instruction and uses its native tools to execute. The structure helps organize, not constrain. + +**Action Message:** +```json +{ + "id": "005", + "from": "commander", + "timestamp": "2026-03-19T10:01:30Z", + "type": "action", + "step_id": 3, + "step_name": "Add responder as member", + "category": "api", + "instruction": "Call POST /sync/teams/walkie-test/members with body {\"pairing_code\": \"amF5YW50...\"}. This adds Machine B as a team member.", + "context": { + "method": "POST", + "path": "/sync/teams/walkie-test/members", + "body": { "pairing_code": "amF5YW50..." } + }, + "expect": { + "description": "Member should be added successfully with status 201", + "status": 201 + }, + "step_timeout": 60 +} +``` + +**Key design**: `instruction` is what the LLM reads. `context` is structured data that helps if the LLM needs precise values. `expect` defines what success looks like — the LLM evaluates whether the result matches. + +**Response Message:** +```json +{ + "id": "006", + "from": "responder", + "timestamp": "2026-03-19T10:01:45Z", + "type": "response", + "in_reply_to": "005", + "step_id": 3, + "result": { + "status": 201, + "body": { "member_tag": "jayant.mac-mini", "device_id": "DEV-MINI", "status": "added" } + }, + "passed": true, + "notes": "API responded as expected. Member added to team.", + "duration_ms": 342 +} +``` + +**Intervention Message:** +```json +{ + "id": "007", + "from": "responder", + "timestamp": "2026-03-19T10:02:15Z", + "type": "intervention", + "step_id": 6, + "reason": "API returned 404: Subscription not found", + "context": "Reconciliation ran but no subscription exists. Possible timing issue — metadata may not have synced yet.", + "tried": ["Waited 30s and retried — same result", "Checked sync_subscriptions table — 0 rows"], + "question": "Should I run reconciliation again, or is there a bug?" +} +``` + +**Guidance Message (from commander after intervention):** +```json +{ + "id": "008", + "from": "commander", + "timestamp": "2026-03-19T10:03:00Z", + "type": "guidance", + "in_reply_to": "007", + "step_id": 6, + "instruction": "Run reconciliation first (POST /sync/reconcile), wait 10s, then retry the subscription accept." +} +``` + +### Action Categories + +Categories are **hints**, not dispatch keys. The LLM responder reads the `instruction` field and uses Claude Code's native tools to execute. The category helps organize messages and tells the LLM what *kind* of thing to do. + +| Category | What It Means | Typical LLM Approach | +|----------|--------------|---------------------| +| `api` | Make an HTTP request to local API server | Use `curl` via Bash, or `httpx` in a Python snippet | +| `cli` | Run a karma CLI command | Execute via Bash tool | +| `db_query` | Query the local SQLite database | Use `sqlite3` via Bash or read with Python | +| `syncthing_check` | Check Syncthing state via its REST API | `curl` to Syncthing API (localhost:8384) | +| `file_check` | Verify file/directory existence or contents | Use Read tool or `ls`/`cat` via Bash | +| `state_snapshot` | Capture DB tables or system state | Combine `sqlite3` queries + file reads | +| `wait` | Poll a condition until met or timeout | Loop with sleep in Bash, checking condition each iteration | +| `shell` | Run an arbitrary shell command | Execute via Bash tool | +| `observe` | Look at something and report what you see (no mutation) | Read files, query APIs, describe state | + +--- + +## Section 7: Cross-Cutting Concerns + +### Trust Model +- **Both machines are developer-owned**. The walkie channel is created by the developer, the responder is started by the developer on their own machine. There is no untrusted input. +- **The git remote is the developer's repo**. Messages flow through a private GitHub repo. No third-party access. +- **The branch is ephemeral**. Created for one test, deleted after. No persistent attack surface. +- **All action types are allowed**, including `shell` and `cli`. The responder is a Claude Code session — it already has full shell access on Machine B. Walkie messages don't grant any capability the responder doesn't already have. +- **No allowlisting or sandboxing** in v1. If the developer writes a scenario with `shell: rm -rf /`, that's on them — same as typing it directly into Claude Code. + +### Identity +- **Within a session**: Commander and responder identify themselves by `member_tag` from SyncConfig +- **Across machines**: Messages use `member_tag` as the author identity (same as sync system) +- **Channel identity**: The branch name `walkie/{test-name}` is the unique channel identifier +- **Message identity**: Sequential integer IDs (001, 002, ...) — no UUIDs needed within a single test + +### Cleanup & Teardown +- `/walkie stop` → write COMPLETED to `channel.json`, push +- `/walkie cleanup` → delete branch locally + `git push origin --delete walkie/{name}` +- If test crashes mid-scenario → branch persists, can be manually deleted +- No DB rows created by walkie itself — cleanup is purely git +- Responder exits autonomous mode when channel state = COMPLETED + +### Migration +- N/A — this is a new product, no prior version + +### Timing & Ordering +| Scenario | Max Delay | Acceptable? | +|----------|-----------|-------------| +| Commander sends action → responder receives | 5-10s (git push + pull cycle) | Yes | +| Responder sends response → commander receives | 5-10s | Yes | +| Full round trip (action → response) | 10-20s + execution time | Yes | +| Responder git pull loop interval | 5s | Yes — configurable | +| Syncthing sync (the thing being tested) | Variable (seconds to minutes) | Test must account for this with `wait` actions | + +**Race condition**: Commander pushes 2 messages before responder pulls → responder processes both in order (sorted by filename/sequence ID). No conflict. + +**Git conflict**: Only one party writes at a time (request-response pattern). Commander waits for response before sending next action. No concurrent writes to same file. No formal checkpoint mechanism needed — the message exchange pattern inherently enforces ordering. The responder cannot execute step N until the commander sends it, and the commander won't send step N+1 until it receives the response for step N. + +### Recovery + +**Responder crash mid-step**: On restart, the responder re-joins the channel (`/walkie join`), reads `channel.json` and `.walkie/messages/` to determine last known state, then sends an **intervention** asking the commander how to proceed. It does NOT auto-retry — some steps are not idempotent, and the LLM can't know if the previous attempt partially succeeded. + +**Commander crash**: The developer restarts the commander session and runs `/walkie status` to see current progress. The commander reads the latest messages from git, determines where the scenario left off, and resumes. If the responder is in WAITING state (sent an intervention), the commander sees it and can respond. + +**Git push fails**: Retry once after 5s. If still fails, report to the developer. Do not silently drop messages. + +**Stale channel**: If a walkie branch exists from a previous crashed run, `/walkie start` with the same name will detect it and ask: "Channel walkie/{name} already exists. Resume or clean up and restart?" + +### Multi-Tenancy / Shared Resources +- Each walkie session is on its own branch — complete isolation +- Multiple walkie sessions can run simultaneously (different branch names) +- The repo itself is shared — walkie branches coexist with feature branches +- `.walkie/` directory is only on walkie branches, never merged to main + +--- + +## Section 8: Verification Matrix + +### 8.1 Channel Lifecycle +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | `/walkie start` creates branch with `.walkie/` directory | `git branch -a`, `ls .walkie/` | [ ] | +| 2 | `channel.json` contains commander info | Read file | [ ] | +| 3 | Responder `/walkie join` updates `channel.json` with responder info | Read file after join | [ ] | +| 4 | `/walkie stop` sets channel state to COMPLETED | Read `channel.json` | [ ] | +| 5 | `/walkie cleanup` deletes branch locally and remotely | `git branch -a` | [ ] | + +### 8.2 Message Exchange +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Commander writes message → push → responder detects within 10s | Timestamp diff | [ ] | +| 2 | Responder writes response → push → commander detects within 10s | Timestamp diff | [ ] | +| 3 | Messages are sequential (no gaps in IDs) | List `.walkie/messages/` | [ ] | +| 4 | Duplicate pull doesn't re-process already-seen messages | Check message tracking state | [ ] | + +### 8.3 Scenario Execution +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | All steps executed in order | Message sequence in `.walkie/messages/` | [ ] | +| 2 | Commander steps executed locally (not sent to responder) | No message for commander-actor steps | [ ] | +| 3 | Responder steps sent as messages and executed remotely | Message exists for responder-actor steps | [ ] | +| 4 | PASSED steps have matching actual vs expected | Response message `passed: true` | [ ] | +| 5 | FAILED steps show actual vs expected diff | Response message with both values | [ ] | +| 6 | Scenario stops on first failure (when `on_fail: stop`) | No messages after failed step | [ ] | +| 7 | `wait` action polls until condition met or timeout | Response shows `waited_seconds` | [ ] | + +### 8.4 Intervention Flow +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Responder sends intervention with context and question | Message type=intervention | [ ] | +| 2 | Commander displays intervention to developer | Terminal output | [ ] | +| 3 | Developer guidance sent back to responder | Message type=guidance | [ ] | +| 4 | Responder resumes after receiving guidance | Next response message after guidance | [ ] | + +### 8.5 Variable Resolution +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Responder publishes pairing_code, device_id to `channel.json` during join | Read `channel.json` after join commit | [ ] | +| 2 | `${{responder.pairing_code}}` resolves correctly in scenario steps | Check resolved action message body | [ ] | +| 3 | Unresolvable variable `${{responder.nonexistent}}` triggers BLOCKED + intervention | Message type=intervention with variable name | [ ] | +| 4 | Commander pulls responder's join commit before resolving variables | Git log shows pull before scenario start | [ ] | + +### 8.6 Commander UX +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Each step prints status line as it completes | Terminal output | [ ] | +| 2 | Remote steps show WAITING while waiting for responder | Terminal output during execution | [ ] | +| 3 | Intervention displays responder's question to developer | Terminal output | [ ] | +| 4 | Developer can type `/walkie stop` to halt scenario | Channel state = COMPLETED, remaining steps SKIPPED | [ ] | + +### 8.7 Recovery +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Responder crash → rejoin → sends intervention with last known state | Message type=intervention after rejoin | [ ] | +| 2 | Commander crash → restart → reads latest messages and resumes | Scenario continues from correct step | [ ] | +| 3 | Stale channel detected on `/walkie start` with existing name | Prompt: "Resume or clean up?" | [ ] | + +### 8.8 Edge Cases +| # | Scenario | Expected Behavior | Pass? | +|---|----------|-------------------|-------| +| E1 | Responder is offline when commander sends action | Message queues in git. Responder processes on next pull | [ ] | +| E2 | Git push fails (network down) | Retry once after 5s. If still fails, report to developer | [ ] | +| E3 | Responder's API server is not running | Intervention: "API server not responding on port 8000" | [ ] | +| E4 | Scenario references `${{responder.pairing_code}}` | Resolved from `channel.json` (published during join) | [ ] | +| E5 | Two walkie sessions on same branch name | Append timestamp suffix to prevent collision | [ ] | +| E6 | Scenario step timeout exceeded | Mark step BLOCKED, notify developer (timeout + 20s for transport) | [ ] | +| E7 | Commander sends `/walkie stop` mid-scenario | All remaining steps SKIPPED, channel COMPLETED | [ ] | +| E8 | Responder crashes mid-execution | On rejoin, sends intervention asking commander how to proceed | [ ] | +| E9 | Responder LLM misinterprets instruction | Result doesn't match expect → FAILED, developer sees actual vs expected | [ ] | +| E10 | Responder improvises successfully (e.g., retries on transient error) | Response notes field explains what it did differently | [ ] | +| E11 | Consecutive commander steps (1→2→3) with no actor switch | All execute locally, no messages sent, no wait | [ ] | + +--- + +## Implementation Components Needed + +### Design Philosophy + +**Claude Code IS the executor. Git IS the transport. Our product is the protocol.** + +We don't build an execution engine — Claude Code already knows how to make API calls, run CLI commands, query databases, read files, and handle errors. We don't build a transport layer — Git already provides branch isolation, push/pull, and commit history. Our product is a *thin protocol layer* that gives two Claude Code sessions a structured way to coordinate. + +| Concern | Provided By | We Build | +|---------|-------------|----------| +| Action execution | Claude Code (LLM + native tools) | Nothing — the LLM reads instructions and uses Bash, Read, Write, etc. | +| Message transport | Git (push/pull to shared remote) | Nothing — standard git commands | +| Branch isolation | Git branches | Just the naming convention (`walkie/{name}`) | +| Natural language interpretation | Claude Code (LLM) | Nothing — the LLM interprets `instruction` field | +| Error handling / improvisation | Claude Code (LLM) | Nothing — the LLM decides when to retry, when to ask for help | +| **Channel lifecycle** | — | **Yes** — create/join/stop/cleanup channel state | +| **Message protocol** | — | **Yes** — JSON message format, sequential IDs, message tracking | +| **Scenario definition** | — | **Yes** — YAML schema, variable resolution, step tracking | +| **Polling loop** | — | **Yes** — git pull every 5s, detect new messages | + +### Claude Code Skills (Entry Points) + +These are Claude Code skills (YAML + system prompt) that instruct the LLM how to follow the walkie protocol. They don't contain execution logic — they teach the LLM the protocol. + +| Skill | Role | What It Teaches the LLM | +|-------|------|------------------------| +| `/walkie start` | Commander | Create walkie branch, init `.walkie/` dir, write `channel.json`, push, display join instructions | +| `/walkie join` | Responder | Checkout branch, read channel, publish local variables to `channel.json`, push, start poll loop, enter autonomous mode | +| `/walkie run` | Commander | Load scenario, resolve variables, execute steps in order, send messages to responder, display step-by-step log | +| `/walkie stop` | Either | Write COMPLETED to `channel.json`, push, exit | +| `/walkie cleanup` | Commander | Delete branch locally + remotely | +| `/walkie status` | Either | Read `channel.json` + latest messages, display current state | + +### Thin Protocol Layer (Python Helpers) + +Minimal Python utilities that the Claude Code skills call. These handle the mechanical parts that are tedious for an LLM to do manually every time (sequential ID generation, JSON schema validation). + +| Module | Purpose | Why Not Just LLM? | +|--------|---------|-------------------| +| `walkie/channel.py` | Read/write `channel.json`, state transitions | Schema consistency across sessions | +| `walkie/messages.py` | Sequential ID generation, message file I/O, "last seen" tracking | IDs must be gap-free; tracking must be reliable | +| `walkie/scenario.py` | Parse YAML scenarios, resolve `${{variables}}`, track step state | Variable resolution needs to be deterministic | + +**Not needed** (Claude Code handles these natively): +- ~~`executor.py`~~ — The LLM reads the instruction and executes using its own tools +- ~~`git_transport.py`~~ — The LLM runs `git push`/`git pull` directly +- ~~`responder.py`~~ — The `/walkie join` skill teaches the LLM the autonomous loop +- ~~`commander.py`~~ — The `/walkie run` skill teaches the LLM the orchestration flow + +### Dependencies +- `pyyaml` — scenario YAML parsing (used by `walkie/scenario.py`) +- `git` — CLI git commands, already available on both machines +- No new infrastructure — uses existing git remote +- No `httpx` — Claude Code makes HTTP calls via `curl` in Bash diff --git a/docs/features/2026-03-19-sync-v4.md b/docs/features/2026-03-19-sync-v4.md new file mode 100644 index 00000000..ee7f9ce2 --- /dev/null +++ b/docs/features/2026-03-19-sync-v4.md @@ -0,0 +1,603 @@ +# Feature Definition: Session Sync (v4) + +## Section 1: Scope & Sub-Features + +### Purpose +Enable Claude Code users to share session data (JSONL, subagents, tool results, plans, todos) across machines and team members without a central server, using Syncthing as P2P transport. + +### Sub-Features +1. **Initialization** — Set up sync identity (user_id, machine_tag) and connect to local Syncthing + - Triggered by: `POST /sync/init` or `karma init` + - Depends on: Syncthing installed and running + +2. **Team Management** — Create/dissolve teams, add/remove members via pairing codes + - Triggered by: Leader creates team, shares pairing code out-of-band + - Depends on: Initialization + +3. **Device Pairing** — Exchange Syncthing device IDs via human-readable codes + - Triggered by: Leader enters member's pairing code + - Depends on: Both devices initialized + +4. **Project Sharing** — Share a project's sessions with team members + - Triggered by: Leader shares project via git identity + - Depends on: Team with active members + +5. **Subscription Management** — Accept/decline/pause project subscriptions with direction control (SEND/RECEIVE/BOTH) + - Triggered by: Member responds to OFFERED subscription (shown in Projects tab of team page) + - Depends on: Project shared, subscription discovered via metadata reconciliation + +6. **Session Packaging** — Bundle local sessions into outbox for Syncthing + - Triggered by: File change in `~/.claude/projects/` (debounced 5s) or initial sync + - Depends on: ACCEPTED subscription with SEND|BOTH direction + +7. **Session Receiving** — Index incoming sessions from Syncthing inbox + - Triggered by: File arrival in inbox folder (watchdog) + - Depends on: ACCEPTED subscription with RECEIVE|BOTH direction + +8. **Reconciliation** — 4-phase pipeline every 60s: team discovery → metadata sync → mesh pair → device lists + - Triggered by: Timer (60s) or manual `POST /sync/reconcile` + - Depends on: At least one team exists + +9. **Member Removal & Auto-Leave** — Clean removal with metadata signals and self-cleanup + - Triggered by: Leader removes member, or member discovers removal signal in metadata + - Depends on: Team membership + +10. **Cross-Team Safety** — Prevent destructive operations in one team from breaking another team's resources + - Triggered by: Any folder cleanup, device unpair, or project removal + - Depends on: Same resource potentially shared across teams + +### Not In Scope +- File-level merge conflict resolution (sessions are append-only JSONL) +- Real-time collaboration (async, 60s cycle) +- Central server or relay — purely P2P +- Session editing/deletion by recipients (receive-only) +- Grouping multiple devices into a single "user" concept (each device = separate member) + +--- + +## Section 2: Actors & Roles + +| Actor | Type | Capabilities | Restrictions | +|-------|------|-------------|-------------| +| **Leader** | human (device) | Create team, add/remove members, share/remove projects, dissolve team, generate join code | Cannot be removed (must dissolve), cannot leave own team | +| **Member** | human (device) | Accept/decline/pause subscriptions, choose direction (SEND/RECEIVE/BOTH), leave team, generate pairing code | Cannot add/remove members, cannot share/remove projects | +| **ReconciliationTimer** | system (60s) | Discover teams (Phase 0), sync metadata (Phase 1), mesh pair devices (Phase 2), manage device lists (Phase 3) | Cannot modify subscriptions, cannot share projects | +| **SessionWatcher** | system (watchdog) | Detect JSONL changes, trigger packaging | Cannot package without ACCEPTED+SEND subscription | +| **RemoteSessionWatcher** | system (watchdog) | Detect incoming files, trigger reindex | Cannot modify sync state | +| **MetadataService** | system | Read/write team.json, member state files, removal signals | Filesystem only, no DB access | +| **Syncthing** | external | Sync files between paired devices, offer pending folders/devices | No awareness of Karma semantics | +| **Frontend** | system | Render sync pages, team detail, subscription cards in Projects tab | Cannot access DB or Syncthing directly | + +**Critical rules:** +- A "member" is a **device**, not a person. One person with 2 machines = 2 members (2 member_tags) +- Leader authority is checked by `device_id` match (`team.is_leader(device_id)`) +- A person's second device has NO special permissions — it's just another member that needs to be added +- Reconciliation runs on EVERY machine independently — each machine has its own SQLite DB + +--- + +## Section 3: Vocabulary + +| Term | Definition | NOT the same as | +|------|-----------|-----------------| +| **member_tag** | `{user_id}.{machine_tag}` — unique device identity in sync | `user_id` (one user can have multiple member_tags) | +| **machine_tag** | Sanitized hostname: lowercase `[a-z0-9-]+`, no `--` | `machine_id` (raw hostname, unsanitized) | +| **user_id** | Human-chosen identifier, **no dots** (dot is the separator in member_tag) | Display name, email, GitHub username | +| **device_id** | Syncthing's 56-char device identifier (opaque) | member_tag (member_tag is human-readable) | +| **encoded_name** | Machine-local path encoded: `/Users/me/repo` → `-Users-me-repo` | git_identity — encoded_name differs per machine | +| **git_identity** | Normalized git remote URL: `owner/repo` (lowercase) — machine-independent | encoded_name (machine-specific), folder_suffix (lossy derivation) | +| **folder_suffix** | Derived from git_identity: `owner-repo` (slashes→hyphens). Used in Syncthing folder IDs | git_identity — `a/b-c` and `a-b/c` would collide | +| **outbox** | Syncthing folder where local sessions are packaged TO (**sendonly**) | inbox — outbox is what I write | +| **inbox** | Syncthing folder where remote sessions arrive FROM a peer (**receiveonly**). Folder ID matches remote's outbox ID | outbox — inbox is what I read from others | +| **metadata folder** | `karma-meta--{team}` (**sendreceive**) — team.json, member states, removal signals | outbox/inbox — metadata is bidirectional | +| **session_packaged** | Event logged when local sessions are bundled into outbox staging dir | "sent" — no network transfer happened; Syncthing handles transport | +| **session_received** | Event logged when remote sessions are indexed into local DB from inbox | "downloaded" — Syncthing synced the files, not HTTP | +| **pairing code** | base64url(`{member_tag}:{device_id}`), grouped in 6-char blocks with dashes | join code (v3 concept, removed) | +| **subscription** | Per-(member, team, project) record tracking acceptance status + direction | membership — subscription is about a project, membership is about the team | +| **direction** | SEND, RECEIVE, or BOTH — controls which Syncthing folders are created | status — direction is orthogonal to accepted/paused | +| **OFFERED** | Subscription created by share_project/metadata discovery. Member hasn't responded yet | ACCEPTED — OFFERED means no Syncthing folders created | +| **ACCEPTED** | Member opted in. Syncthing folders created based on direction | ACTIVE (member status) — member can be ACTIVE with OFFERED subscriptions | +| **ADDED** | Member created in leader's DB but not yet confirmed via metadata | ACTIVE — ADDED means peer hasn't published state file yet | +| **reconciliation cycle** | 4-phase pipeline: discovery → metadata → mesh pair → device lists | manual sync — reconciliation is automatic every 60s | + +--- + +## Section 4: State Models + +### State Model: Team + +| State | Meaning | +|-------|---------| +| ACTIVE | Team is operational | +| DISSOLVED | Team has been dissolved by leader | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| — | ACTIVE | `create_team()` | Create leader member, write metadata, register Syncthing folder | Yes (name is PK) | +| ACTIVE | DISSOLVED | `dissolve_team()` (leader only) | Cleanup all Syncthing folders, CASCADE delete members/projects/subs | No — irreversible | + +### State Model: Member + +| State | Meaning | Who | +|-------|---------|-----| +| ADDED | Created in leader's DB, not yet confirmed | New member before Phase 1 activation | +| ACTIVE | Confirmed via metadata, full participant | All participating members | +| REMOVED | Removed by leader | Former member | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| — | ADDED | `add_member()` (leader) | Pair device, share folders (best-effort), create OFFERED subs | Yes | +| — | ACTIVE | `create_team()` (leader self-adds as ACTIVE) | — | Yes | +| ADDED | ACTIVE | Phase 1 discovers peer state file | **Backfill OFFERED subs** for shared projects missed during ADDED state | Yes | +| ADDED/ACTIVE | REMOVED | `remove_member()` (leader) | Record removal, write removal signal, remove from device lists, unpair if no other teams. **Leader cannot remove themselves — must dissolve instead.** | No | + +**Critical questions answered:** +1. **Initiator state**: Leader stays ACTIVE. Adding a member doesn't change leader's state. +2. **Late joiner**: Member added AFTER project shared → gets OFFERED subs via `add_member()`. Member added BEFORE project shared → `share_project()` creates OFFERED if member is ACTIVE; if ADDED, Phase 1 backfills on activation. +3. **Idempotency**: `add_member()` uses INSERT OR REPLACE. Safe to call twice. +4. **Cross-system sync**: Member status propagates via metadata state files → Phase 1 reads them. +5. **Cascade**: Removing member → removal signal in metadata → target machine's Phase 1 auto-leaves. + +### State Model: SharedProject + +| State | Meaning | +|-------|---------| +| SHARED | Project is actively shared with team | +| REMOVED | Project removed from team | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| — | SHARED | `share_project()` (leader) | Create leader's ACCEPTED sub, OFFERED subs for active members, create outbox, publish metadata | Yes | +| SHARED | REMOVED | `remove_project()` (leader) | Decline all subs, cleanup Syncthing folders (cross-team safe), publish metadata | No | + +### State Model: Subscription + +| State | Meaning | Syncthing Folders | +|-------|---------|-------------------| +| OFFERED | Invitation, member hasn't responded | None | +| ACCEPTED | Opted in with direction | Outbox (SEND/BOTH), Inbox (RECEIVE/BOTH) | +| PAUSED | Temporarily suspended | Folders remain but not actively syncing | +| DECLINED | Opted out | None | + +| From | To | Triggered By | Side Effects | Idempotent? | +|------|-----|-------------|-------------|-------------| +| — | OFFERED | `share_project()`, Phase 1 discovery, or Phase 1 backfill | None (no folders) | Yes | +| OFFERED | ACCEPTED | Member calls `accept_subscription(direction)` | Create outbox (SEND/BOTH) + inboxes (RECEIVE/BOTH), publish metadata | Yes | +| OFFERED | DECLINED | Member calls `decline_subscription()` | Publish metadata | Yes | +| ACCEPTED | PAUSED | Member calls `pause_subscription()` | Publish metadata (folders remain) | Yes | +| PAUSED | ACCEPTED | Member calls `resume_subscription()` | Re-apply direction (create any missing folders), publish metadata | Yes | +| ACCEPTED | DECLINED | Member calls `decline_subscription()` | Publish metadata | Yes | +| ANY | DECLINED | Phase 1 project removal (leader removed project) | Publish metadata | Yes | +| DECLINED | OFFERED | Member calls `reopen_subscription()` | Publish metadata, member can then accept again | Yes | + +**Critical questions answered:** +1. **Initiator state**: Leader gets ACCEPTED/BOTH automatically when sharing. Leader is inside the subscription system. +2. **Late joiner**: Member ADDED after project shared → gets OFFERED via `add_member()`. Member activated after project shared → Phase 1 backfills OFFERED. +3. **Cross-system sync**: Subscription status published to metadata → other machines' Phase 1 reads and updates local records (offered→accepted, any→declined). +4. **Cascade**: Accepting subscription → Phase 3 on other machines adds this device to their folder device lists (within 60s). +5. **Direction change**: Only from ACCEPTED state. Removes outbox if losing SEND, creates outbox if gaining SEND. + +--- + +## Section 5: Workflows + +### Workflow 5.1: Team Creation + +**Trigger**: User clicks "Create Team" in UI +**Preconditions**: Sync initialized (`/sync/init` completed) + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Leader | `POST /sync/teams` | `{name}` | Name collision | 409 Conflict | +| 2 | TeamService | Create Team row (ACTIVE) | team_name, leader_member_tag, leader_device_id | — | — | +| 3 | TeamService | Create Leader as ACTIVE Member | same | — | — | +| 4 | MetadataService | Write team.json + leader state file | team info | Filesystem error | Logged, not fatal | +| 5 | FolderManager | Register `karma-meta--{team}` in Syncthing | team_name | Syncthing API down | Fatal — 500 | +| 6 | EventRepository | Log `team_created` | — | — | — | + +**Postconditions:** +- [ ] Team row exists with status=ACTIVE +- [ ] Leader is ACTIVE member in sync_members +- [ ] Metadata folder exists on filesystem +- [ ] Metadata folder registered in Syncthing (sendreceive) +- [ ] team.json contains leader info (created_by, leader_device_id) +- [ ] Leader's state file exists in members/ subdirectory + +### Workflow 5.2: Add Member (Leader Adds via Pairing Code) + +**Trigger**: Leader enters pairing code in "Add Member" dialog +**Preconditions**: Team exists, leader is authenticated (device_id matches) + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | PairingService | Decode pairing code | code → member_tag + device_id | Invalid code | 400 Bad Request | +| 2 | TeamService | Auth check: is_leader(by_device) | leader_device_id | Not leader | 403 Forbidden | +| 3 | TeamService | Check if device was previously removed | device_id | Was removed | 409 Conflict | +| 4 | TeamService | Create Member (status=ADDED) | member_tag, device_id | — | — | +| 5 | DeviceManager | Pair new device in Syncthing | device_id | Syncthing down | **Best-effort** — logged, continues | +| 6 | FolderManager | Add new device to metadata folder's device list | device_id | Syncthing down | Best-effort | +| 7 | FolderManager | Add new device to ALL shared project outbox folders | device_id, all folder_ids | Syncthing down | Best-effort | +| 8 | MetadataService | Update metadata with all members | all members | Filesystem error | Logged | +| 9 | TeamService | Create OFFERED subscription for EACH shared project | projects list | — | — | +| 10 | EventRepository | Log `member_added` | — | — | — | + +**Postconditions:** +- [ ] New member exists with status=ADDED +- [ ] OFFERED subscriptions exist for ALL currently shared projects +- [ ] Syncthing metadata folder shared with new device (best-effort) +- [ ] Leader's outbox folders shared with new device (best-effort) +- [ ] Metadata files updated with new member info + +**What if step 5-7 fail (Syncthing down)?** +- DB operations (steps 4, 9) still succeed +- Reconciliation Phase 2 (mesh pair) and Phase 3 (device lists) will retry on next cycle +- Member will eventually discover team via Phase 0 once Syncthing recovers + +### Workflow 5.3: Joiner Discovers Team (Reconciliation) + +**Trigger**: ReconciliationTimer fires (60s) on new member's machine +**Preconditions**: Syncthing has synced the metadata folder from leader + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | Phase 0 | Scan Syncthing config for `karma-meta--*` folders | — | Syncthing down | Skip cycle | +| 2 | Phase 0 | Find new metadata folder, no local Team row | folder_id | — | — | +| 3 | Phase 0 | Read team.json from metadata folder | team_name | File not synced yet | Skip — retry next cycle | +| 4 | Phase 0 | Create Team row locally | leader info from team.json | — | — | +| 5 | Phase 0 | Create self as ACTIVE Member | own member_tag, device_id | — | — | +| 6 | Phase 1 | Read all member state files | — | — | — | +| 7 | Phase 1 | Discover leader as new member → save ACTIVE | leader info | — | — | +| 8 | Phase 1 | Read leader's `projects` list from metadata | — | Key missing | Skip project sync (guard) | +| 9 | Phase 1 | For each project: create SharedProject + OFFERED sub for self | git_identity, folder_suffix | — | — | +| 10 | Phase 2 | Pair with leader's device | leader_device_id | Syncthing down | Retry next cycle | +| 11 | Phase 3 | Compute device lists (no accepted subs yet → empty) | — | — | — | + +**Postconditions:** +- [ ] Team exists locally on joiner's machine +- [ ] Self is ACTIVE member +- [ ] Leader known as ACTIVE member +- [ ] SharedProject rows exist for all shared projects +- [ ] OFFERED subscriptions exist for all shared projects +- [ ] Frontend can now show offered subscriptions in Projects tab + +**Timing**: This happens within 60s of metadata folder syncing. If team.json hasn't synced yet, Phase 0 skips and retries next cycle (another 60s). + +### Workflow 5.4: Member Accepts Subscription + +**Trigger**: Member clicks "Accept" on subscription card in team's Projects tab, choosing direction (SEND/RECEIVE/BOTH) +**Preconditions**: OFFERED subscription exists, member is ACTIVE + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | ProjectService | Transition OFFERED → ACCEPTED | direction | Invalid transition | 409 Conflict | +| 2 | ProjectService | `_apply_sync_direction()` | — | — | — | +| 2a | (if SEND/BOTH) | Create outbox: `karma-out--{member_tag}--{suffix}` (sendonly) | — | Syncthing down | Retried by Phase 3 | +| 2b | (if RECEIVE/BOTH) | For each active teammate: create inbox matching their outbox ID (receiveonly) | teammate device_ids | Syncthing down | Retried by Phase 3 | +| 3 | ProjectService | Publish member's subscriptions to metadata | projects + subs | Filesystem error | Best-effort | +| 4 | EventRepository | Log `subscription_accepted` | direction | — | — | + +**Postconditions:** +- [ ] Subscription status = ACCEPTED with chosen direction +- [ ] Outbox folder exists in Syncthing (if SEND/BOTH) +- [ ] Inbox folders exist for each active teammate (if RECEIVE/BOTH) +- [ ] Member's metadata state file updated with subscription status + direction +- [ ] WatcherManager can now package sessions to outbox (if SEND/BOTH) + +**What happens next (on other machines within 60s)?** +- Phase 1 reads this member's metadata → sees subscription = "accepted" +- Updates local subscription record (offered → accepted) +- Phase 3 adds this member's device to outbox folder device lists +- Syncthing starts syncing files + +### Workflow 5.5: Share New Project (Members Already Exist) + +**Trigger**: Leader clicks "Share Project" in team's Projects tab +**Preconditions**: Team has ACTIVE members + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | ProjectService | Auth check: leader only | by_device | Not leader | 403 | +| 2 | ProjectService | Create SharedProject (SHARED) | git_identity, folder_suffix | — | — | +| 3 | ProjectService | Create ACCEPTED/BOTH subscription for leader | leader_member_tag | — | — | +| 4 | ProjectService | For each ACTIVE non-leader member: create OFFERED subscription | member list | — | — | +| 5 | ProjectService | **Skip ADDED members** (not active yet) | — | — | Backfilled on activation | +| 6 | FolderManager | Create leader's outbox folder (if encoded_name provided) | — | Syncthing down | Phase 3 retries | +| 7 | MetadataService | Publish leader's updated project list | projects + subs | Best-effort | — | +| 8 | EventRepository | Log `project_shared` | — | — | — | + +**On each member's machine (Phase 1, within 60s):** +- Reads leader's updated projects list from metadata +- Sees new project not in local sync_projects +- Creates SharedProject row + OFFERED subscription for self +- Subscription appears in Projects tab as invitation card + +**Combination: Member was ADDED when project shared:** +- `share_project()` skips ADDED members (line 86: `if member.is_active`) +- When Phase 1 activates this member (ADDED→ACTIVE), backfill creates OFFERED subscription +- Delay: up to 2 reconciliation cycles (activation + next project sync) + +**Combination: Member does NOT have the project locally:** +- Still gets OFFERED subscription +- Can accept and RECEIVE sessions without having the repo cloned +- Sessions indexed as remote sessions under resolved local project + +### Workflow 5.6: Remove Member + +**Trigger**: Leader clicks "Remove" on member in Members tab +**Preconditions**: Team exists, target is not the leader + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | TeamService | Auth check: leader only | by_device | Not leader | 403 | +| 2 | TeamService | Transition member → REMOVED | member_tag | Already removed | 409 | +| 3 | MemberRepository | Record removal (prevent re-add from stale metadata) | device_id | — | — | +| 4 | MetadataService | Write removal signal to `removed/{member_tag}.json` | removed_by | — | — | +| 5 | FolderManager | Remove device from ALL team folder device lists | device_id, all suffixes + tags | — | — | +| 6 | DeviceManager | Unpair device **only if not in any other team** | device_id | — | — | +| 7 | EventRepository | Log `member_removed` | — | — | — | + +**On removed member's machine (Phase 1, within 60s):** +- Reads removal signals from metadata +- Finds own member_tag → triggers `_auto_leave()` +- Cleans up all Syncthing folders for this team (cross-team safe) +- Unpairs devices not shared with other teams +- Deletes team from local DB +- Logs `member_auto_left` event + +### Workflow 5.7: Session Packaging & Receiving + +**Trigger**: JSONL file modified in `~/.claude/projects/` (packaging) or file arrives in inbox (receiving) + +**Packaging (outbox):** + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | SessionWatcher | Detect file change, debounce 5s | file path | — | — | +| 2 | WatcherManager | **Policy gate**: check ACCEPTED + SEND/BOTH subscription | subscription status | No subscription | Skip packaging | +| 3 | SessionPackager | Discover sessions (exclude live, apply limit) | project dir | — | — | +| 4 | SessionPackager | Copy JSONL + subagents + tool results + plans + todos | session files | Disk full | Force recent_100 if <10GiB | +| 5 | SessionPackager | Build manifest.json (git_identity, sessions, skills) | — | — | — | +| 6 | SessionPackager | Write to outbox staging dir | outbox path from DB | Outbox not found | Fallback to remote-sessions/ | +| 7 | Syncthing | Sync outbox to all devices in folder's device list | — | Network down | Retries automatically | + +**Receiving (inbox):** + +| # | Actor | Action | Data | Can Fail? | Failure Handling | +|---|-------|--------|------|-----------|-----------------| +| 1 | RemoteSessionWatcher | Detect file arrival in inbox | — | — | — | +| 2 | Indexer | Read manifest.json, resolve git_identity to local project | git_identity | No local match | Index under git_identity key | +| 3 | Indexer | Index sessions into local DB | JSONL files | Corrupt file | Skip silently | +| 4 | EventRepository | Log `session_received` | session_uuid, member_tag | — | — | + +--- + +## Section 6: Data Contracts + +### Identity Scope Table + +| Identifier | Scope | Use For | Do NOT Use For | +|-----------|-------|---------|---------------| +| encoded_name | one machine | Local file paths, URL routing, frontend navigation | Cross-machine matching, Syncthing folder IDs | +| git_identity | all machines | Project matching/dedup, subscription PK, metadata | Local navigation (differs per machine) | +| folder_suffix | all machines | Syncthing folder IDs (outbox/inbox naming) | Display to user (lossy derivation from git_identity) | +| member_tag | all machines | Device identification, folder IDs, metadata filenames | Display name (use user_id for display) | +| device_id | Syncthing | Peer addressing, connection status | User identification, display | + +### API → Frontend Contracts + +**GET /sync/teams/{name}** (Team Detail) +| Field | Type | Key? | Notes | +|-------|------|------|-------| +| name | string | PK | Team name | +| leader_member_tag | string | — | Who is leader | +| status | string | — | "active" or "dissolved" | +| members[] | Member[] | member_tag | — | +| projects[] | SharedProject[] | git_identity | — | +| subscriptions[] | Subscription[] | (member_tag, team_name, project_git_identity) | — | + +**GET /sync/teams/{name}/project-status** (Projects Tab) +| Field | Type | Key? | Notes | +|-------|------|------|-------| +| git_identity | string | **List key** | Machine-independent — use as `{#each}` key | +| folder_suffix | string | — | — | +| encoded_name | string | — | Resolved locally — may be null if project not on this machine | +| name | string | — | Human-readable, resolved from encoded_name | +| subscription_counts | object | — | {offered, accepted, paused, declined} | +| local_count | number | — | JSONL files on this machine | +| packaged_count | number | — | Sessions in outbox | +| received_counts | object | — | By remote_user_id | +| gap | number | — | max(0, local - packaged) | + +**GET /sync/subscriptions** (Member's Subscriptions) +| Field | Type | Key? | Notes | +|-------|------|------|-------| +| member_tag | string | PK part | — | +| team_name | string | PK part | — | +| project_git_identity | string | PK part + **list key** | — | +| status | string | — | offered/accepted/paused/declined | +| direction | string | — | send/receive/both | + +**GET /sync/members** (Cross-team Members) +| Field | Type | Key? | Notes | +|-------|------|------|-------| +| name | string | — | user_id extracted from member_tag | +| device_id | string | **List key** | Unique across all members | +| connected | boolean | — | From Syncthing connection status | +| is_you | boolean | — | device_id matches local device | +| team_count | number | — | — | +| teams[] | string[] | — | Team names | + +**GET /sync/status** (Sync Status) +| Field | Type | Notes | +|-------|------|-------| +| configured | boolean | Whether /sync/init has been run | +| user_id | string | — | +| machine_id | string | Raw hostname | +| member_tag | string | user_id.machine_tag | +| device_id | string | Syncthing device ID (may be null if Syncthing down) | +| teams[] | object[] | {name, status, leader_member_tag, member_count} | + +### Frontend Rendering Rules +- **List keys**: Always use `git_identity` for project lists, `device_id` for member lists, `member_tag` for team-scoped member lists +- **SSR**: Fetch data in `+page.server.ts`, NOT in `onMount` or `$effect` (SSR crashes) +- **Polling**: Use `onMount` with interval for live data (connection status, pending devices) +- **Member counts**: Always exclude self (`is_you`) from displayed counts + +### Metadata State File Schema (member state file) +```json +{ + "member_tag": "user.machine", + "device_id": "SYNCTHING_DEVICE_ID", + "user_id": "user", + "machine_tag": "machine", + "status": "active", + "projects": [ + { "git_identity": "owner/repo", "folder_suffix": "owner-repo", "encoded_name": "-Users-..." } + ], + "subscriptions": { + "owner/repo": { "status": "accepted", "direction": "both" } + }, + "updated_at": "2026-03-19T..." +} +``` + +### Syncthing Folder ID Conventions +| Type | Format | Direction | +|------|--------|-----------| +| Outbox | `karma-out--{member_tag}--{folder_suffix}` | sendonly (owner), receiveonly (receivers) | +| Metadata | `karma-meta--{team_name}` | sendreceive (all members) | + +--- + +## Section 7: Cross-Cutting Concerns + +### Identity +- **Within one machine**: `encoded_name` for file paths, `member_tag` for sync identity +- **Across machines**: `git_identity` (git remote URL, normalized) for project matching, `device_id` for Syncthing addressing +- **Divergence**: If a project is renamed or git remote changes, git_identity breaks. No auto-migration — must remove and re-share +- **Machine-specific vs independent**: encoded_name is machine-specific. git_identity, member_tag, device_id, folder_suffix are machine-independent + +### Cleanup & Teardown +| Operation | What's Cleaned Up | Cross-Team Safety | +|-----------|-------------------|-------------------| +| Remove member | Device removed from folder device lists, removal signal written, device unpaired | **Only unpair if no other team membership** | +| Remove project | All subscriptions declined, outbox/inbox folders deleted | **Check if other teams share same folder_suffix before deleting** | +| Dissolve team | Write removal signals for all non-leader members, clean all folders, team CASCADE deleted | Check cross-team folder sharing. Remote members auto-leave via Phase 1 removal signal detection. | +| Leave team | Same as auto-leave: folders cleaned, devices unpaired if exclusive | Check cross-team folder sharing | +| Auto-leave (Phase 1) | Same as leave | Check cross-team folder sharing | + +**Cross-team folder check**: `cleanup_team_folders()` and `cleanup_project_folders()` accept `conn` parameter. Before deleting an outbox folder, they query `sync_subscriptions + sync_projects` to check if any OTHER team has active subscriptions for the same `(member_tag, folder_suffix)`. If so, folder is preserved. + +### Migration (v3 → v4) +- **Schema**: v19 migration drops ALL v3 sync tables, creates v4 tables fresh. **Breaking** — no data migration +- **Code**: v3 service files deleted (`16fec93`). v3 CLI sync code removed (`0d30952`) +- **Frontend**: v3 pending folder UI removed from ProjectsTab (`f34ee7c`). v3 join-team flow removed (`8a958a3`) +- **Fresh install**: v19 migration must DROP IF EXISTS the v4 table names too, because SCHEMA_SQL creates them first (`f3534dd` fix) + +### Timing & Ordering +| Scenario | Max Delay | Acceptable? | +|----------|-----------|-------------| +| Leader adds member → member sees team | 60s (Phase 0) + Syncthing sync time | Yes — pairing code exchange takes longer | +| Leader shares project → member sees offer | 60s (Phase 1) | Yes | +| Member accepts subscription → leader's device lists update | 60s (Phase 1 sub sync) + 60s (Phase 3 device lists) = up to 120s | Marginal — consider manual refresh | +| Session packaged → received on other machine | Depends on Syncthing (typically seconds) | Yes | +| Removal signal → auto-leave | 60s (Phase 1) | Yes | + +**First run vs subsequent**: Phase 0 bootstraps team from metadata folder. If team.json hasn't synced yet, Phase 0 skips and retries next cycle. Worst case: 120s for first discovery. + +**Timer during manual operation**: ReconciliationTimer creates a dedicated SQLite connection per thread. Manual operations use the API connection. No lock contention, but state may be stale by one cycle. + +### Multi-Tenancy / Shared Resources +- **Same project in 2 teams**: folder_suffix is derived from git_identity, so it's identical. `list_accepted_for_suffix()` returns subs from all teams, but **Phase 3 filters by `team.name`** when computing device lists — device lists are team-scoped, NOT cross-team unions. This prevents data leaks between teams. +- **Same device in 2 teams**: Member rows exist in both teams. Unpair only checks `get_by_device()` across all teams — only unpairs if no alive memberships remain. +- **Direction change safety**: `change_direction()` checks if another team's subscription (same member + suffix) still needs the outbox before deleting it. +- **Destructive operation safety**: Every cleanup function checks cross-team ownership before deleting folders or unpairing devices. + +--- + +## Section 8: Verification Matrix + +### 8.1 Team Lifecycle +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Team created with leader as ACTIVE member | Query `sync_teams` + `sync_members` | [ ] | +| 2 | Metadata folder registered in Syncthing | `GET /sync/detect` or Syncthing UI | [ ] | +| 3 | team.json exists with correct leader info | Read `~/.claude_karma/metadata-folders/karma-meta--{team}/team.json` | [ ] | +| 4 | Dissolve cleans up ALL Syncthing folders | Check Syncthing config for any `karma-*--{team}` folders | [ ] | +| 5 | Dissolve CASCADE deletes members, projects, subs | Query all sync_* tables | [ ] | + +### 8.2 Member Management +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Add member creates ADDED row | Query `sync_members` WHERE status='added' | [ ] | +| 2 | OFFERED subs created for ALL shared projects | Count `sync_subscriptions` for new member_tag | [ ] | +| 3 | Metadata folder shared with new device | Check Syncthing folder config for device_id | [ ] | +| 4 | Non-leader cannot add members | `POST /sync/teams/{name}/members` returns 403 | [ ] | +| 5 | Previously removed device cannot re-join | Check `sync_removed_members` table | [ ] | +| 6 | Remove member writes removal signal | Check `removed/{member_tag}.json` exists | [ ] | +| 7 | Unpair only if no other team membership | Query `sync_members` by device_id across teams | [ ] | + +### 8.3 Joiner Discovery (Reconciliation) +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Phase 0 bootstraps team from metadata folder | Query `sync_teams` on joiner's machine | [ ] | +| 2 | Phase 0 creates self as ACTIVE (not ADDED) | Query `sync_members` on joiner's machine | [ ] | +| 3 | Phase 1 discovers leader from state files | Leader appears in joiner's `sync_members` | [ ] | +| 4 | Phase 1 discovers projects from leader's metadata | `sync_projects` populated on joiner's machine | [ ] | +| 5 | Phase 1 creates OFFERED subs for each project | `sync_subscriptions` on joiner's machine | [ ] | +| 6 | Guard: skip project sync if leader hasn't published projects key | No crash, no orphan records | [ ] | +| 7 | Phase 2 pairs joiner ↔ leader devices | Both devices in Syncthing config | [ ] | + +### 8.4 Subscription Flow +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Accept creates outbox (SEND/BOTH) | Syncthing folder `karma-out--{member_tag}--{suffix}` exists (sendonly) | [ ] | +| 2 | Accept creates inboxes for each teammate (RECEIVE/BOTH) | Syncthing folder matching teammate's outbox ID exists (receiveonly) | [ ] | +| 3 | Metadata updated with subscription status | Check `members/{member_tag}.json` subscriptions field | [ ] | +| 4 | Phase 1 syncs accepted status to leader's DB | Leader's `sync_subscriptions` shows accepted | [ ] | +| 5 | Phase 3 adds device to outbox device lists | Check Syncthing folder device list | [ ] | +| 6 | Decline removes from future device list computation | Phase 3 skips declined subscriptions | [ ] | +| 7 | Pause keeps folders but no new data | Subscription status=paused, folders remain | [ ] | +| 8 | Direction change creates/removes outbox correctly | Switch BOTH→RECEIVE: outbox removed. RECEIVE→BOTH: outbox created | [ ] | + +### 8.5 Session Sync +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Sessions packaged to correct outbox folder | Check outbox dir for manifest.json + JSONL files | [ ] | +| 2 | Policy gate: no packaging without ACCEPTED+SEND sub | Stop watcher, verify no files in outbox | [ ] | +| 3 | manifest.json contains git_identity (not encoded_name) | Read manifest.json | [ ] | +| 4 | Received sessions indexed under correct local project | Query sessions DB for remote sessions | [ ] | +| 5 | Live sessions excluded from packaging | Start a session, verify it's not in outbox | [ ] | +| 6 | Incremental packaging (skip unchanged files) | Package twice, verify mtimes unchanged | [ ] | + +### 8.6 Cross-Team Safety +| # | Assertion | Verify By | Pass? | +|---|-----------|-----------|-------| +| 1 | Same project in 2 teams: remove from Team A preserves for Team B | Delete from A, verify B's folders still exist | [ ] | +| 2 | Same device in 2 teams: remove from Team A doesn't unpair | Remove member from A, verify device still in Syncthing config | [ ] | +| 3 | Device lists merge across teams | Share same project in 2 teams, verify device list is union | [ ] | +| 4 | Leave team doesn't corrupt other team's state | Leave A, verify B's sync_* tables untouched | [ ] | + +### 8.7 Edge Cases +| # | Scenario | Expected Behavior | Pass? | +|---|----------|-------------------|-------| +| E1 | Member added AFTER project shared | Gets OFFERED sub via `add_member()` | [ ] | +| E2 | Member ADDED (not active) when project shared | No sub at share time. Backfilled when Phase 1 activates member | [ ] | +| E3 | Leader shares project, no members exist | Only leader's ACCEPTED sub created. OFFERED subs created when members added | [ ] | +| E4 | Subscription accepted on Machine B, checked from Machine A | Phase 1 syncs status via metadata within 60s | [ ] | +| E5 | team.json not synced yet when Phase 0 runs | Phase 0 skips, retries next cycle (60s) | [ ] | +| E6 | leader hasn't published projects key yet | Phase 1 skips project sync (guard at line 251) | [ ] | +| E7 | Syncthing down during add_member | DB operations succeed, Syncthing ops are best-effort. Reconciliation retries | [ ] | +| E8 | folder_suffix collision (a/b-c vs a-b/c) | Both derive to `a-b-c`. Currently unhandled — same outbox folder used | [ ] | +| E9 | Accept subscription twice | Idempotent — INSERT OR REPLACE | [ ] | +| E10 | Remove already-removed member | 409 Conflict from domain model state transition | [ ] | +| E11 | Fresh install with v19 migration | DROP IF EXISTS sync_projects before CREATE — no collision | [ ] | +| E12 | Person with 2 devices in same team | 2 separate members, 2 separate subscriptions, independent packaging | [ ] | +| E13 | Member leaves team, then leader adds them back | Must use new pairing code. Old removal record checked via `was_removed()` | [ ] | +| E14 | Reconciliation timer fires during manual accept_subscription | Separate SQLite connections — no lock contention | [ ] | +| E15 | Member has no local clone of shared project | Can still RECEIVE sessions — indexed as remote sessions | [ ] | +| E16 | Bob declines subscription, later wants to re-accept | `POST .../reopen` → DECLINED→OFFERED, then accept again | [ ] | +| E17 | Leader tries to remove themselves | `InvalidTransitionError` — must use dissolve instead | [ ] | +| E18 | Team dissolved while members are offline | Removal signals written for all members → Phase 1 auto-leave on next cycle | [ ] | +| E19 | Same project in 2 teams, Phase 3 device lists | Device lists are team-scoped — no cross-team pollution | [ ] | +| E20 | Change direction BOTH→RECEIVE when other team needs outbox | Outbox preserved — cross-team subscription check prevents deletion | [ ] | diff --git a/docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md b/docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md new file mode 100644 index 00000000..41c1a5f1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md @@ -0,0 +1,640 @@ +# Session ↔ Ticket Linking — Design + +**Status:** Draft — revised post-review +**Date:** 2026-05-13 +**Author:** Jayant Devkar (with Claude) +**Reviewers:** oh-my-claudecode:critic, feature-dev:code-reviewer (both REVISE → addressed inline below) + +## Problem + +Today, a Claude Code session in karma has no concept of *why* it exists. Users +often start a session to work on a specific ticket (GitHub Issue, Linear, Jira), +but karma has no way to surface that context. Reviewing "what work was done for +ticket X" requires manual correlation across the dashboard, the ticket tracker, +and git history. + +We want karma to **organize sessions around tickets**: store the link, display +ticket context next to sessions, and let a ticket page show every session that +touched it. This is a *post-hoc audit* feature, not a workflow-automation one. + +## Goals + +- A session can be linked to one or more tickets (Linear, Jira, GitHub Issues). +- Links can be established three ways: an in-session slash command, a + branch-name auto-detector, and a dashboard input. +- The karma dashboard shows ticket badges on session cards, a `/tickets` index, + and a per-ticket detail page listing linked sessions. +- Ticket metadata (title, status) is cached in karma's SQLite DB; the agent + populates it via its already-configured MCP servers (Linear MCP, GitHub MCP, + Atlassian MCP) at link time. +- The system works in a degraded mode (URL-only) when MCP isn't available. +- Resumed sessions (which share a slug but get new UUIDs each resume) are + deduplicated to a single link per ticket. + +## Non-goals (v1) + +- **No write-back to providers.** Karma never modifies ticket state, posts + comments, or moves cards. Read-only by design. +- **No karma-side provider adapters.** Karma's backend has no Linear/Jira/GitHub + API client and no provider credentials. The agent + MCP is the only metadata + source. +- **No fuzzy auto-detection from user prompts.** Heuristics like scanning chat + for `LINEAR-123` are explicitly excluded — false positives outweigh value. +- **No SessionStart context injection.** The agent is not told about linked + tickets via a hook. If the agent needs ticket details in-session, the user + invokes the appropriate MCP directly. +- **No status-change automation, webhooks, or pull notifications.** +- **No primary-ticket flag.** All links on a session are displayed equally. +- **No API auth.** Karma assumes its API is loopback-only (`localhost:8000`), + same as today. If that changes, this feature inherits whatever scheme is + added globally. + +## Architecture overview + +Karma stays a recipient/observer. Nothing in the karma backend talks to Linear, +Jira, or GitHub directly. + +``` + ┌──────────────────────────┐ + user types "/link-ticket-to- │ Agent (Claude Code) │ + session LINEAR-123" in session │ invokes skill → │ + ─────────────────► │ resolves session UUID, │ + │ fetches title/status │ + │ via Linear/GitHub MCP, │ + │ POSTs link + refresh │ + └────────────┬─────────────┘ + │ 1) POST /sessions/{uuid}/tickets + │ {ref, provider, source} + │ 2) PUT /tickets/{provider}/{key} + │ {title, status, metadata_json} + ▼ +git checkout feat/LINEAR-123 ┌─────────────────────────────────────┐ + │ │ Karma API (FastAPI) │ + ▼ │ - URL/ref parser │ +SessionStart hook │ - Transactional upsert into │ +ticket_branch_detector.py ───► │ tickets + session_tickets │ +(bare-ref + slug from │ - link_source precedence on conflict│ + live-sessions/) └──────────────┬──────────────────────┘ + ▼ +dashboard "Link ticket" ───► ~/.claude_karma/metadata.db (SQLite) +input on /sessions/[uuid] │ + ▼ + Frontend: /tickets, + /tickets/[provider]/[external_key], + badges on session/project pages +``` + +### Components + +1. `api/db/schema.py` — add new tables to **both** the `SCHEMA_SQL` block + (fresh-install path, lines ~15–217) **and** an incremental migration block + `if current_version < 11:` (current `SCHEMA_VERSION = 10`). The repo + pattern is to update both every time; this is verified in + `api/db/schema.py:247-249`. +2. `api/services/ticket_parser.py` — pure function: ref/URL → `TicketRef`. No + I/O, no git shell-outs. +3. `api/models/ticket.py` — Pydantic models (`Ticket`, `SessionTicketLink`, + `TicketRef`, `TicketUpsertRequest`, `TicketMetadataUpdate`). All use + `ConfigDict(frozen=True)` per the repo pattern (`api/CLAUDE.md` → + "Frozen Models"). +4. `api/routers/tickets.py` — REST endpoints. Registered in `api/main.py` + following the existing `app.include_router(...)` pattern (see + `api/main.py:23-40`). Mounted with **no** prefix; endpoint paths are + spelled out explicitly because the router serves both session-scoped + (`/sessions/{uuid}/tickets`) and ticket-centric (`/tickets/...`) routes. +5. `api/db/queries.py` — new functions for upsert/link/unlink/list. Reads use + `sqlite_read()`; writes use `get_writer_db()` from `api/db/connection.py` + (the existing pattern, see `api/routers/sessions.py:1737` and + `api/routers/admin.py:31,55,73`). +6. `hooks/ticket_branch_detector.py` — SessionStart hook. POSTs to karma's + API following the same pattern as `hooks/session_title_generator.py:241` + (`post_title()` uses `urllib.request` to POST to `localhost:8000`). Silent + on any failure. +7. `~/.claude/skills/link-ticket-to-session/SKILL.md` — agent skill. Agent + resolves its own session UUID via `~/.claude_karma/live-sessions/` + lookup (see *Path 1* below for the locked-down recipe). +8. Frontend additions in `frontend/src/routes/`: + - `tickets/+page.svelte` (index) + - `tickets/[provider]/[external_key]/+page.svelte` (detail — keyed on + the stable external identifier, not the SQLite PK; matches the repo's + convention of semantic slugs in routes) + - `frontend/src/lib/components/TicketBadge.svelte` (3 variants: inline, + card, pill) + - `frontend/src/lib/components/TicketLinkInput.svelte` + - Updates to `sessions/[uuid]/+page.svelte` (tickets section) + - Updates to `projects/[encoded_name]/+page.svelte` (tickets tab/card) + - Reuses existing `SessionCard` from + `frontend/src/lib/components/SessionCard.svelte` (verified to exist; + exported from `frontend/src/lib/index.ts:7`). + +No new Python or npm dependencies. + +## Data model + +Two new tables. No FK to `sessions` (it's a JSONL-mirror table and may not +exist yet when the branch-detect hook fires at `SessionStart`). Soft +references via `session_uuid TEXT` follow the existing reconciler pattern. + +**Both tables must be added to two places in `api/db/schema.py`:** + +1. The `SCHEMA_SQL` constant (fresh-install path), so a brand-new DB has them. +2. A new `if current_version < 11: conn.executescript(...)` block, so existing + DBs at v10 get upgraded. The block bumps `SCHEMA_VERSION` to 11. + +Omitting either breaks one install path. The repo's pattern is to maintain +both (verified against the v8 → v10 history in the same file). + +```sql +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK (provider IN ('linear','jira','github')), + external_key TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT, + status TEXT, + metadata_json TEXT CHECK (metadata_json IS NULL OR length(metadata_json) <= 65536), + metadata_updated_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, external_key) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_provider ON tickets(provider); + +CREATE TABLE IF NOT EXISTS session_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_uuid TEXT NOT NULL, -- soft ref, no FK + session_slug TEXT, -- nullable; populated when known + ticket_id INTEGER NOT NULL, + link_source TEXT NOT NULL CHECK (link_source IN ('branch','slash_command','dashboard')), + linked_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + UNIQUE(session_uuid, ticket_id) +); + +CREATE INDEX IF NOT EXISTS idx_session_tickets_session ON session_tickets(session_uuid); +CREATE INDEX IF NOT EXISTS idx_session_tickets_slug ON session_tickets(session_slug); +CREATE INDEX IF NOT EXISTS idx_session_tickets_ticket ON session_tickets(ticket_id); + +-- Partial unique index: dedupes resumed-session links by slug. +-- Each resume gets a new session_uuid but shares a slug; without this, +-- the branch-detect hook would create a fresh row on every resume. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_session_tickets_slug_ticket + ON session_tickets(session_slug, ticket_id) + WHERE session_slug IS NOT NULL; +``` + +### Constraint rationale + +- **`provider` CHECK** — three providers in v1. New providers add a row to + the CHECK constraint in a future migration. Rejects typos at write time. +- **`metadata_json` length cap (64 KB)** — Linear payloads with comments and + custom fields can easily exceed 50 KB. The slash command MUST strip + description, comments, and labels arrays before POSTing; only assignee, + state, and short fields belong in the cache. Future work: switch to a + separate `ticket_metadata_blob` table if 64 KB isn't enough. +- **`UNIQUE(provider, external_key)`** — exactly one `tickets` row per + real-world ticket; linking from multiple sessions reuses the row. +- **`UNIQUE(session_uuid, ticket_id)`** — re-invoking the slash command on + the same session for the same ticket is a no-op. +- **Partial unique `(session_slug, ticket_id) WHERE session_slug IS NOT NULL`** — + resumed sessions share a slug but get new UUIDs. This index ensures that + three resumes of `feat/LINEAR-123` produce one link, not three, **provided + the writer populates `session_slug`**. Writers without slug context (early + SessionStart, before live-sessions file is written) skip this dedup and + fall back to per-UUID linking, which is acceptable. +- **`link_source` precedence on conflict** — see *Endpoint behavior* below. + Higher-trust sources upgrade lower-trust ones; never the reverse. +- **Datetime defaults** — `TEXT DEFAULT (datetime('now'))` matches the + existing schema convention (see `schema_version`, `projects.updated_at` in + `api/db/schema.py`). Removes clock-skew risk across hook / agent / dashboard. + +## Link establishment + +All three paths converge on `POST /sessions/{uuid}/tickets`. The skill +path additionally calls `PUT /tickets/{provider}/{external_key}` to publish +the MCP-fetched metadata. Split into two calls so each is a proper idempotent +operation in the HTTP sense (the original spec conflated "create link" with +"refresh metadata"; the critic flagged this and we fixed it). + +### Path 1 — Agent skill (richest payload, agent-driven) + +The skill lives at `skills/link-ticket-to-session/SKILL.md` in this +repo and is symlinked or copied into `~/.claude/skills/link-ticket-to-session/` +at install time. Body uses `${CLAUDE_SESSION_ID}` directly — confirmed +during planning to be a documented substitution inside Claude Code +skills — so there's no need to fish the UUID out of +`~/.claude_karma/live-sessions/` files. The slug-based dedup that the +live-sessions lookup was designed to support is handled at the API +layer via the partial unique index on `(session_slug, ticket_id)`: +the branch-detect hook fills slug in when known; the skill leaves +it out; resumed sessions still collapse to one row. + +The skill's `description` is written to fire on explicit linking +requests (the slash form `/link-ticket-to-session` OR natural +language like "link this session to LINEAR-123") and explicitly NOT +to auto-trigger on passing mentions of ticket keys in conversation. +`allowed-tools` is constrained to Bash + the three ticket-provider +MCP servers so the skill can't reach into unrelated tooling. + +```markdown +--- +description: Link the current Claude Code session to a ticket and cache its title/status in karma +argument-hint: +--- + +You are linking the current Claude Code session +(`${CLAUDE_SESSION_ID}`) to ticket: **$ARGUMENTS** + +Steps: + +1. **Parse $ARGUMENTS.** It may be a URL or a short ref. Recognized forms: + - Linear: `LINEAR-123` or `https://linear.app/.../issue/ABC-123` + - Jira: `PROJ-45` or `https://*.atlassian.net/browse/PROJ-45` + - GitHub: `owner/repo#42` or `https://github.com/owner/repo/issues/42`. + A bare `#N` is NOT accepted — always qualify with `owner/repo`. + +2. **Identify the provider** (`linear` | `jira` | `github`). + +3. **Fetch ticket details via MCP** (linear / atlassian / github), + pulling at minimum `title`, `status`, `url`. Strip large fields + (description, comments, labels arrays) — the karma cache caps + `metadata_json` at 64 KB. If the MCP isn't available, proceed + without title/status. + +4. **POST the link** (idempotent — creates link, does NOT touch metadata): + curl -s -X POST "http://localhost:8000/sessions/${CLAUDE_SESSION_ID}/tickets" \ + -H 'Content-Type: application/json' \ + -d '{"ref":"","provider":"","url":"","source":"slash_command"}' + +5. **PUT the metadata** (only when MCP fetch succeeded): + curl -s -X PUT "http://localhost:8000/tickets//" \ + -H 'Content-Type: application/json' \ + -d '{"title":"","status":"<status>"}' + +6. **Confirm to the user** with a one-line summary. +``` + +The skill delegates the MCP lookup to the agent, so karma never needs +creds. If MCP isn't installed for that provider, we still record the +link with bare metadata; the user can backfill via the dashboard or +another invocation. + +### Path 2 — Branch auto-detect (silent, metadata-less) + +`hooks/ticket_branch_detector.py` runs on `SessionStart`: + +```python +# Pseudocode +import json, re, sys, subprocess +from pathlib import Path +import urllib.request + +payload = json.load(sys.stdin) # captain-hook SessionStart event +session_uuid = payload.get("session_id") +cwd = payload.get("cwd") +if not (session_uuid and cwd): + sys.exit(0) + +config = load_config() # see "Config file" below +if not config.get("branch_detect_enabled", False): + sys.exit(0) + +branch = git_current_branch(cwd) # 'feat/LINEAR-123-fix-login' +if not branch: + sys.exit(0) + +slug = lookup_slug_from_live_sessions(cwd) # nullable; populated if known + +for pattern in config["ticket_branch_patterns"]: + m = re.search(pattern["regex"], branch) + if not m: + continue + ref = m.group("key") if "key" in m.groupdict() else m.group(0) + body = { + "ref": ref, + "provider": pattern["provider"], + "session_slug": slug, + "source": "branch", + } + post_karma(f"/sessions/{session_uuid}/tickets", body) + break +``` + +The pattern `lookup_slug_from_live_sessions(cwd)` scans +`~/.claude_karma/live-sessions/*.json` for the entry matching `cwd`. If no +entry exists yet (live-sessions tracker runs concurrently and may write after +us), `slug` is `None` — that's fine; the dedup-on-resume falls back to the +per-UUID unique constraint. + +The hook follows the prior-art pattern of +`hooks/session_title_generator.py:241` (`post_title()` already does +hook → `localhost:8000` POSTs via `urllib.request`). All errors are caught +and the hook exits 0 (silent) so it never blocks `SessionStart`. Errors are +appended to `~/.claude_karma/logs/ticket_branch_detector.log`. + +### Path 3 — Dashboard manual + +Two surfaces: + +- **On `/sessions/[uuid]`** — a "Link ticket" input (paste URL or key) → + `POST /sessions/{uuid}/tickets` with `source=dashboard`. Title/status start + empty. Bare `#N` for GitHub is rejected with an inline error + ("include the `owner/repo` prefix"). +- **On `/tickets`** — an "Add ticket" form (paste URL) creates the `tickets` + row standalone; from there, the user can multi-select sessions to link. + +### URL / ref parser (shared) + +`api/services/ticket_parser.py` — pure function, no I/O, no git shell-outs: + +```python +def parse_ticket_ref(s: str, hint_provider: str | None = None) -> TicketRef | None: + """Tries in order: + 1. Linear URL linear.app/.../issue/ABC-123 + 2. Jira URL *.atlassian.net/browse/ABC-123 + 3. GitHub URL github.com/owner/repo/issues/N → key='owner/repo#N' + 4. GitHub short owner/repo#N → key='owner/repo#N' + 5. ALPHA-NUM key e.g. ABC-123 — requires hint_provider; never assumed. + + Returns TicketRef(provider, external_key, url) or None. + + A bare '#N' (no owner/repo) is NOT supported — the API server cannot + resolve git remotes; callers must qualify GitHub refs themselves. The + slash command does this from shell context; the branch hook composes + from git remote in the hook process; the dashboard surfaces an error. + """ +``` + +This removes the v1-draft's `git remote get-url` fallback inside the parser, +which the critic correctly flagged as impossible from the API server (the +POST body has no `cwd` and uvicorn has no notion of the caller's directory). + +### Endpoint behavior + +#### `POST /sessions/{uuid}/tickets` — create link (idempotent) + +Body: `{ref, provider?, url?, session_slug?, source}`. The body does NOT +include `title` or `status` — those are set exclusively via `PUT /tickets/...`. + +``` +BEGIN TRANSACTION; + +1. ticket_ref = parse_ticket_ref(ref, hint_provider=provider) + if ticket_ref is None → ROLLBACK, return 400 {error, hint} + +2. ticket_id = upsert into tickets: + INSERT INTO tickets (provider, external_key, url, first_seen_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT (provider, external_key) DO UPDATE + SET url = excluded.url + RETURNING id; + +3. INSERT INTO session_tickets + (session_uuid, session_slug, ticket_id, link_source, linked_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT (session_uuid, ticket_id) DO UPDATE + SET link_source = CASE + -- precedence: slash_command (3) > dashboard (2) > branch (1) + WHEN session_tickets.link_source = 'branch' AND excluded.link_source IN ('dashboard','slash_command') + THEN excluded.link_source + WHEN session_tickets.link_source = 'dashboard' AND excluded.link_source = 'slash_command' + THEN excluded.link_source + ELSE session_tickets.link_source + END, + session_slug = COALESCE(session_tickets.session_slug, excluded.session_slug) + RETURNING id; + +COMMIT; + +Return 200 {link, ticket}. +``` + +The whole upsert runs in **one transaction** so concurrent POSTs can't see +half-state. `RETURNING id` is supported in SQLite 3.35+; the project already +uses modern SQLite features. + +The `link_source` precedence rule means: a branch-detect link followed by a +user-confirmed skill invocation upgrades to `slash_command`; the reverse never +happens. Auditing "which links did the user explicitly confirm" works +cleanly. + +`session_slug` is filled in on conflict if it was previously NULL — so a +hook that wrote without slug, followed by a slash command that knows the +slug, ends up populated. + +#### `PUT /tickets/{provider}/{external_key}` — refresh metadata + +Body: `{title?, status?, metadata_json?}`. Each field is independently +optional. Behavior: + +```sql +UPDATE tickets + SET title = COALESCE(?, title), + status = COALESCE(?, status), + metadata_json = COALESCE(?, metadata_json), + metadata_updated_at = datetime('now') + WHERE provider = ? AND external_key = ?; +``` + +`COALESCE` preserves existing non-null values when the caller passes `null`. +The slash command's degraded-mode fallback (MCP fetch failed → skip PUT) +relies on this. + +Returns 200 `{ticket}` (full row) or 404 if the ticket isn't in the registry +(the caller forgot the POST first). + +#### `DELETE /sessions/{uuid}/tickets/{ticket_id}` — unlink + +Removes the `session_tickets` row. Does NOT delete the `tickets` row +(another session may still be linked). + +#### Other read endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/sessions/{uuid}/tickets` | List tickets linked to one session. | +| GET | `/tickets` | List all tickets with `session_count` per row. Query: `?provider=`, `?q=`. | +| GET | `/tickets/{provider}/{external_key}` | Ticket detail. | +| GET | `/tickets/{provider}/{external_key}/sessions` | Sessions linked to this ticket (joined with `sessions`). | +| PATCH | `/tickets/{provider}/{external_key}` | Manual edit of title/status from the dashboard (URL-only fallback). Same semantics as PUT but distinguished in the audit log. | + +All ticket-centric routes use the stable `(provider, external_key)` pair as +the URL identifier, **not** the SQLite autoincrement `id`. URLs survive a +DB rebuild and are human-readable. + +### Orphan cleanup + +A `session_tickets` row whose `session_uuid` never appears in `sessions` +(e.g., session was killed before its JSONL was written) becomes an orphan. +Policy: delete orphans on a TTL of 7 days, via a periodic task in +`api/services/` (mirrors existing reconciler pattern). Implementation +detail for v1 plan: a simple `DELETE FROM session_tickets WHERE +session_uuid NOT IN (SELECT uuid FROM sessions) AND linked_at < datetime('now','-7 days')` +run from a startup hook or a cron-style task. + +### Config file + +`~/.claude_karma/config.json` (new): + +```json +{ + "branch_detect_enabled": false, + "ticket_branch_patterns": [ + {"regex": "(?P<key>[A-Z][A-Z0-9_]+-\\d+)", "provider": "linear"} + ] +} +``` + +Loader (in `hooks/ticket_branch_detector.py`): + +```python +import json +from pathlib import Path + +DEFAULT_CONFIG = { + "branch_detect_enabled": False, + "ticket_branch_patterns": [], +} + +def load_config() -> dict: + path = Path.home() / ".claude_karma" / "config.json" + if not path.exists(): + return DEFAULT_CONFIG + try: + return {**DEFAULT_CONFIG, **json.loads(path.read_text())} + except Exception: + return DEFAULT_CONFIG +``` + +**Opt-in by default.** `branch_detect_enabled=False` until the user creates +the config. This prevents surprise links on personal-projects directories +and contains the blast radius of a regex bug. + +A future PR can add per-project overrides; out of scope for v1. + +## API surface (full) + +`api/routers/tickets.py`, registered in `api/main.py` with no prefix: + +| Method | Path | Purpose | Idempotent | +|---|---|---|---| +| POST | `/sessions/{uuid}/tickets` | Create link. Body: `{ref, provider?, url?, session_slug?, source}`. Returns `{link, ticket}`. | Yes (link only; metadata untouched) | +| GET | `/sessions/{uuid}/tickets` | List tickets linked to one session. | Yes | +| DELETE | `/sessions/{uuid}/tickets/{ticket_id}` | Unlink. | Yes | +| PUT | `/tickets/{provider}/{external_key}` | Refresh metadata (agent-driven, post-MCP fetch). Body: `{title?, status?, metadata_json?}`. Returns `{ticket}` or 404. | Yes (per HTTP semantics — same input yields same state) | +| PATCH | `/tickets/{provider}/{external_key}` | Manual metadata edit from dashboard. | Yes | +| GET | `/tickets` | List with `session_count` per row. Query: `?provider=`, `?q=`. | Yes | +| GET | `/tickets/{provider}/{external_key}` | Ticket detail. | Yes | +| GET | `/tickets/{provider}/{external_key}/sessions` | Sessions linked to one ticket. | Yes | + +Reads use `sqlite_read()` (from `api/db/connection.py:144`). Writes use +`get_writer_db()` (from `api/db/connection.py:45`) — the same pattern used +in `api/routers/sessions.py:1737` and `api/routers/admin.py:31`. Pydantic +models in `api/models/ticket.py` use `ConfigDict(frozen=True)`. + +## Frontend surfaces + +SvelteKit + Svelte 5 runes. + +| Route / surface | What it shows | +|---|---| +| `/tickets` (new) | Table: provider icon · key · title (or `—`) · status · session count · last linked. Filter by provider; search by key or title. | +| `/tickets/[provider]/[external_key]` (new) | Header: ticket badge + click-through to provider URL. Body: list of linked sessions (reuses `SessionCard`) with project, model, time. | +| `/sessions/[uuid]` (existing) | New "Tickets" section near the top. Linked-ticket badges with unlink buttons; `<TicketLinkInput>` for adding more. | +| `/projects/[encoded_name]` (existing) | New "Tickets" tab/card. Tickets touched by any session in this project — derived join (`SELECT DISTINCT tickets.* FROM tickets JOIN session_tickets ON ... JOIN sessions ON sessions.uuid = session_tickets.session_uuid WHERE sessions.project_encoded_name = ?`). | + +**Route param choice**: `[provider]/[external_key]` (not `[ticket_id]`) +because the SQLite autoincrement PK isn't a stable external identifier — a +DB rebuild would break all bookmarked URLs. Matches the repo's convention +of semantic slugs (`[session_slug]`, `[project_slug]`, `[plugin_id]`). + +`<TicketBadge>` component (in `frontend/src/lib/components/`): provider +icon (lucide-svelte) + monospace key + optional title + click-through to +`url`. Three variants: `inline` (in lists), `card` (on ticket detail), +`pill` (on session cards). + +`<TicketLinkInput>` (in `frontend/src/lib/components/`): text input + +optional provider dropdown (only shown if input parses as a bare key). +Calls `POST /sessions/{uuid}/tickets`. Optimistic update. + +**Scaling note**: the `/tickets` index query is `GROUP BY ticket_id COUNT(*)` +over `session_tickets`. Acceptable through ~100K rows. If `session_tickets` +grows large (heavy branch-detect usage), add a materialized counter on +`tickets.session_count` updated via trigger. Out of scope for v1. + +## Error handling + +| Failure | Behavior | +|---|---| +| Skill invoked, MCP unavailable | Agent posts the link, skips the PUT; karma stores bare link. Agent tells user "linked, but couldn't fetch title". | +| Skill invoked, karma API down | Agent reports "couldn't reach karma at :8000". User can retry later via dashboard. | +| Branch-detect hook, any error | Silent exit 0. Never blocks `SessionStart`. Errors logged to `~/.claude_karma/logs/ticket_branch_detector.log`. No retry queue — a 4-hour offline period during a feature branch means lost links. Acceptable for v1. | +| Branch-detect hook, slug lookup empty | Proceeds without slug. Falls back to per-UUID linking. Slug gets populated later if a slash command runs. | +| Parser can't recognize ref | API returns `400 {error, hint}`. Dashboard surfaces inline; the skill agent reports it. | +| Bare GitHub `#N` from dashboard | 400 with hint "include `owner/repo` prefix". | +| Duplicate POST (same session + ticket) | Per upsert behavior: idempotent for link itself; `link_source` may upgrade per precedence rule. Returns 200. | +| Concurrent POSTs racing on same `(provider, external_key)` | Wrapped in one transaction with `INSERT ... ON CONFLICT DO UPDATE RETURNING`. SQLite serializes writers via the writer connection — no torn writes. | +| PUT with no prior POST | 404 — caller forgot to create the link first. | +| `metadata_json` over 64 KB | DB CHECK constraint fails → 400 returned to caller. The skill instructs the agent to strip large fields before POSTing, but this is a safety net. | + +## Testing + +| Layer | Tests | Path | +|---|---|---| +| Parser | Table-driven: every URL format, bare keys, ambiguous input, bare `#N` rejection, garbage. | `api/tests/test_ticket_parser.py` | +| Schema | (a) Fresh install applies SCHEMA_SQL and both tables exist + indices + CHECK constraints fire. (b) v10 → v11 upgrade applies the incremental block. (c) Replay (v11 → v11) is a no-op. | `api/tests/test_schema.py` | +| Endpoints | POST link / PUT refresh / DELETE unlink / GET list. Idempotency: re-POST yields same link, no spurious row. Precedence: branch → slash_command upgrades; slash_command → branch does not downgrade. PUT-before-POST = 404. Slug dedup: resumed sessions share one row. metadata cap rejection. | `api/tests/api/test_tickets.py` | +| Hook | Feed JSON payload via stdin, mock `git symbolic-ref`, assert POST. Negatives: no branch, no match, hook disabled, karma down (silent exit 0), config file missing (silent exit 0). | `hooks/tests/test_ticket_branch_detector.py` | +| Frontend | Playwright: (1) link from session page → see on `/tickets` → click through to detail; (2) branch-detect bare link (no metadata) → badge renders correctly; (3) unlink → row disappears; (4) URL-only fallback PATCH from dashboard. | `frontend/tests/` | + +Test paths: hook tests live with the hook code under `hooks/tests/`, not +under `api/tests/`, matching the project structure (`hooks/` is at the repo +root, not inside `api/`). + +## Risks accepted for v1 + +- **Loopback-only API.** No auth. A user with port-forwarding open exposes + the karma DB to drive-by writes. Karma's existing API has no auth either — + this feature inherits the same posture, doesn't make it worse. +- **No hook retry queue.** If karma is down when the branch-detect hook + fires, the link is lost. Manual recovery via dashboard. +- **Slug-less branch-detect at very early SessionStart.** If `SessionStart` + fires before `live_session_tracker.py` writes the live-sessions file, our + hook can't populate slug. Resumed sessions linked in this window get + per-UUID rows; the dashboard sees them as separate links. Cosmetic only. +- **`metadata_json` 64 KB cap.** Future heavy fields (long Jira descriptions) + may be truncated. + +## What's explicitly out of scope for v1 + +- **Write-back to providers** (no comments, no state moves). +- **Built-in provider adapters in karma backend** (karma never holds creds). +- **Auto-detection from user prompts.** +- **Status-change automation** (link → "In Progress", PR open → "In Review"). +- **Webhooks / pull notifications.** +- **Per-project branch-detect config.** +- **Materialized `tickets.session_count`** (until rows justify it). +- **Linking subagents directly to tickets** (inherit from parent session). + +## Verified facts (post-review) + +- `SCHEMA_VERSION = 10` at `api/db/schema.py:12` → spec proposes v11. ✅ +- `sqlite_read()` exists at `api/db/connection.py:144`. ✅ +- `get_writer_db()` exists at `api/db/connection.py:45` and is the writer + pattern (verified used in `api/routers/sessions.py:1737`, + `api/routers/admin.py:31`). ✅ (The previous draft incorrectly referenced + a nonexistent `sync_queries.py` — fixed.) +- `hooks/session_title_generator.py:241` already POSTs to `localhost:8000` + from a hook via `urllib.request.post_title()` — prior art, not a new + pattern. ✅ +- `SessionCard` exists at `frontend/src/lib/components/SessionCard.svelte`. ✅ +- `CORSMiddleware` allows `http://localhost:5173` and `http://localhost:3000` + in `api/main.py:146-152`. No auth middleware. ✅ +- `api/CLAUDE.md` confirms `ConfigDict(frozen=True)` for all models. ✅ +- Existing routes use semantic slugs as params (`[session_slug]`, + `[project_slug]`, `[plugin_id]`) — spec aligns by using + `[provider]/[external_key]` instead of `[ticket_id]`. ✅ + +## Open questions + +None blocking. All review findings are addressed inline above. diff --git a/frontend/package.json b/frontend/package.json index 4505fe17..41ee269e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.1", + "version": "0.2.0", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/frontend/src/app.css b/frontend/src/app.css index 3e1b91e0..2a2cf5ab 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -59,6 +59,26 @@ --info: #3b82f6; --info-subtle: rgba(59, 130, 246, 0.1); + /* Ticket Provider Identity (industry-flavored, no brand logos) + * Each provider has bg + subtle (low-alpha tint) + fg (letter-mark text). + * fg flips dark in dark mode where bgs lighten — keeps AA contrast. */ + --provider-linear: #5e6ad2; + --provider-linear-subtle: rgba(94, 106, 210, 0.12); + --provider-linear-fg: #ffffff; + --provider-jira: #0052cc; + --provider-jira-subtle: rgba(0, 82, 204, 0.10); + --provider-jira-fg: #ffffff; + --provider-github: #1f2937; + --provider-github-subtle: rgba(31, 41, 55, 0.08); + --provider-github-fg: #ffffff; + + /* Normalized Ticket Status (todo / active / review / done / closed) */ + --status-todo: #94a3b8; + --status-active: #3b82f6; + --status-review: #f59e0b; + --status-done: #10b981; + --status-closed: #64748b; + /* Model Family Colors */ --model-opus: #7c3aed; --model-opus-subtle: rgba(124, 58, 237, 0.1); @@ -117,6 +137,8 @@ --nav-indigo-subtle: rgba(99, 102, 241, 0.1); --nav-amber: #d97706; --nav-amber-subtle: rgba(217, 119, 6, 0.08); + --nav-cyan: #0e7490; + --nav-cyan-subtle: rgba(14, 116, 144, 0.10); /* Live Session Status Background Tints (subtle card backgrounds) */ --status-starting-bg: rgba(139, 92, 246, 0.05); @@ -280,6 +302,24 @@ select:focus { --info: #60a5fa; --info-subtle: rgba(96, 165, 250, 0.15); + /* Ticket Provider Identity (dark) — lightened bgs need dark fg for AA */ + --provider-linear: #8b95e8; + --provider-linear-subtle: rgba(139, 149, 232, 0.18); + --provider-linear-fg: #1a1c4e; + --provider-jira: #4c9aff; + --provider-jira-subtle: rgba(76, 154, 255, 0.15); + --provider-jira-fg: #001e4d; + --provider-github: #cbd5e1; + --provider-github-subtle: rgba(203, 213, 225, 0.10); + --provider-github-fg: #0f172a; + + /* Normalized Ticket Status (dark) */ + --status-todo: #71717a; + --status-active: #60a5fa; + --status-review: #fbbf24; + --status-done: #34d399; + --status-closed: #94a3b8; + /* Model Colors (lighter for dark mode) */ --model-opus: #a78bfa; --model-opus-subtle: rgba(167, 139, 250, 0.2); @@ -331,6 +371,8 @@ select:focus { --nav-indigo-subtle: rgba(129, 140, 248, 0.18); --nav-amber: #fbbf24; --nav-amber-subtle: rgba(251, 191, 36, 0.15); + --nav-cyan: #22d3ee; + --nav-cyan-subtle: rgba(34, 211, 238, 0.18); /* Live Session Status Background Tints (dark mode - slightly higher opacity) */ --status-starting-bg: rgba(167, 139, 250, 0.08); @@ -386,6 +428,25 @@ select:focus { --warning-subtle: rgba(251, 191, 36, 0.15); --info: #60a5fa; --info-subtle: rgba(96, 165, 250, 0.15); + + /* Ticket Provider Identity (dark — prefers-color-scheme fallback) */ + --provider-linear: #8b95e8; + --provider-linear-subtle: rgba(139, 149, 232, 0.18); + --provider-linear-fg: #1a1c4e; + --provider-jira: #4c9aff; + --provider-jira-subtle: rgba(76, 154, 255, 0.15); + --provider-jira-fg: #001e4d; + --provider-github: #cbd5e1; + --provider-github-subtle: rgba(203, 213, 225, 0.10); + --provider-github-fg: #0f172a; + + /* Normalized Ticket Status (dark — prefers-color-scheme fallback) */ + --status-todo: #71717a; + --status-active: #60a5fa; + --status-review: #fbbf24; + --status-done: #34d399; + --status-closed: #94a3b8; + --model-opus: #a78bfa; --model-opus-subtle: rgba(167, 139, 250, 0.2); --model-sonnet: #60a5fa; @@ -432,6 +493,8 @@ select:focus { --nav-indigo-subtle: rgba(129, 140, 248, 0.18); --nav-amber: #fbbf24; --nav-amber-subtle: rgba(251, 191, 36, 0.15); + --nav-cyan: #22d3ee; + --nav-cyan-subtle: rgba(34, 211, 238, 0.18); /* Live Session Status Background Tints (dark mode) */ --status-starting-bg: rgba(167, 139, 250, 0.08); --status-active-bg: rgba(52, 211, 153, 0.08); diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 3d2b591a..7f7c51d8 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -1719,3 +1719,93 @@ export interface HookScriptDetail { line_count: number | null; error: string | null; } + +// ============================================ +// Tickets (session ↔ ticket linking) +// ============================================ + +export type TicketProvider = 'linear' | 'jira' | 'github'; +export type TicketLinkSource = 'branch' | 'slash_command' | 'dashboard'; + +export interface Ticket { + id: number; + provider: TicketProvider; + external_key: string; + url: string; + title: string | null; + status: string | null; + metadata_json: string | null; + metadata_updated_at: string | null; + first_seen_at: string; +} + +export interface SessionTicketLink { + id: number; + session_uuid: string; + session_slug: string | null; + ticket_id: number; + link_source: TicketLinkSource; + linked_at: string; +} + +/** Row returned from GET /sessions/{uuid}/tickets — ticket fields plus link metadata inline. */ +export interface SessionTicketRow extends Ticket { + link_id: number; + link_source: TicketLinkSource; + linked_at: string; + session_slug: string | null; +} + +/** Live-session metadata attached to ticket-detail session rows when the + * session is currently active (not yet in the indexed `sessions` table). + * See api/services/ticket_session_enrichment.py. */ +export interface LiveSessionMeta { + status: LiveSessionState; + started_at: string | null; + last_updated: string | null; + cwd: string | null; +} + +/** Row returned from GET /tickets/{provider}/{external_key}/sessions — + * a session_tickets join with sessions (LEFT JOIN), enriched with live + * data when the indexed `sessions` row doesn't exist yet. */ +export interface TicketDetailSessionRow { + link_id: number; + session_uuid: string; + session_slug: string | null; + link_source: TicketLinkSource; + linked_at: string; + sessions_slug: string | null; + project_encoded_name: string | null; + start_time: string | null; + end_time: string | null; + initial_prompt: string | null; + live: LiveSessionMeta | null; +} + +/** Row returned from GET /tickets — ticket fields plus aggregate counts. */ +export interface TicketListItem { + id: number; + provider: TicketProvider; + external_key: string; + url: string; + title: string | null; + status: string | null; + first_seen_at: string; + metadata_updated_at: string | null; + session_count: number; + last_linked_at: string | null; +} + +export interface CreateLinkRequest { + ref: string; + provider?: TicketProvider; + url?: string; + session_slug?: string; + source: TicketLinkSource; +} + +export interface CreateLinkResponse { + link: SessionTicketLink; + ticket: Ticket; +} diff --git a/frontend/src/lib/components/GlobalSessionCard.svelte b/frontend/src/lib/components/GlobalSessionCard.svelte index 863ffeed..a10c8c8a 100644 --- a/frontend/src/lib/components/GlobalSessionCard.svelte +++ b/frontend/src/lib/components/GlobalSessionCard.svelte @@ -11,6 +11,7 @@ Bot } from 'lucide-svelte'; import type { SessionWithContext, LiveSessionSummary } from '$lib/api-types'; + import { projectHrefFromSession } from '$lib/utils/project-url'; import { statusConfig } from '$lib/live-session-config'; import { formatRelativeTime, @@ -126,7 +127,7 @@ </script> <a - href="/projects/{session.project_slug || session.project_encoded_name}/{urlIdentifier}" + href={projectHrefFromSession(session, `/${urlIdentifier}`)} aria-label="Session {displayName}, {displayProjectName}, {displayMessageCount} messages{liveStatusText}" class=" flex flex-col h-full diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index 8408110c..98320c76 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -1,12 +1,64 @@ <script lang="ts"> + import type { ComponentType } from 'svelte'; import { page } from '$app/stores'; - import { Menu, X, Settings } from 'lucide-svelte'; + import { + Menu, + X, + Settings, + FolderOpen, + MessageSquare, + Ticket, + FileText, + Bot, + Wrench, + Terminal, + Cable, + Webhook, + Puzzle, + LineChart, + History + } from 'lucide-svelte'; import LogoIcon from '$lib/assets/LogoIcon.svelte'; let mobileMenuOpen = $state(false); let isHome = $derived($page.url.pathname === '/'); + /** + * Single source of truth for top navigation. Each item carries the + * lucide icon used on its homepage NavigationCard and the brand color + * token name so the active pill matches the user's mental map from the + * homepage. Items render icon-only (with `title` tooltip) when inactive + * and expand to icon+label in a brand-tinted pill when active. + */ + interface NavItem { + href: string; + label: string; + icon: ComponentType; + color: string; // matches --nav-{color} and --nav-{color}-subtle in app.css + } + + const NAV_ITEMS: NavItem[] = [ + { href: '/projects', label: 'Projects', icon: FolderOpen, color: 'blue' }, + { href: '/sessions', label: 'Sessions', icon: MessageSquare, color: 'teal' }, + { href: '/tickets', label: 'Tickets', icon: Ticket, color: 'amber' }, + { href: '/plans', label: 'Plans', icon: FileText, color: 'yellow' }, + { href: '/agents', label: 'Agents', icon: Bot, color: 'purple' }, + { href: '/skills', label: 'Skills', icon: Wrench, color: 'orange' }, + { href: '/commands', label: 'Commands', icon: Terminal, color: 'red' }, + { href: '/tools', label: 'Tools', icon: Cable, color: 'indigo' }, + { href: '/hooks', label: 'Hooks', icon: Webhook, color: 'cyan' }, + { href: '/plugins', label: 'Plugins', icon: Puzzle, color: 'violet' }, + { href: '/analytics', label: 'Analytics', icon: LineChart, color: 'green' }, + { href: '/archived', label: 'Archived', icon: History, color: 'gray' } + ]; + + let currentPath = $derived($page.url.pathname); + + function isActive(href: string, pathname: string): boolean { + return pathname.startsWith(href); + } + function toggleMobileMenu() { mobileMenuOpen = !mobileMenuOpen; } @@ -73,99 +125,41 @@ </a> </div> - <!-- Center: Desktop Navigation --> + <!-- Center: Desktop Navigation (icon-first compact) + Direction C from design brief 2026-05-19. Icons only when + inactive; icon + label in a brand-tinted pill when active. + `title` provides the tooltip on hover. Each item's color + mirrors its homepage NavigationCard color for continuity. --> <nav - class="hidden md:flex items-center justify-center gap-4 overflow-visible" + class="hidden md:flex items-center justify-center gap-0.5" aria-label="Main navigation" > - <a - href="/projects" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/projects')} - aria-current={$page.url.pathname.startsWith('/projects') ? 'page' : undefined} - > - Projects - </a> - <a - href="/sessions" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/sessions')} - aria-current={$page.url.pathname.startsWith('/sessions') ? 'page' : undefined} - > - Sessions - </a> - <a - href="/plans" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/plans')} - aria-current={$page.url.pathname.startsWith('/plans') ? 'page' : undefined} - > - Plans - </a> - <a - href="/agents" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/agents')} - aria-current={$page.url.pathname.startsWith('/agents') ? 'page' : undefined} - > - Agents - </a> - <a - href="/skills" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/skills')} - aria-current={$page.url.pathname.startsWith('/skills') ? 'page' : undefined} - > - Skills - </a> - <a - href="/commands" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/commands')} - aria-current={$page.url.pathname.startsWith('/commands') ? 'page' : undefined} - > - Commands - </a> - <a - href="/tools" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/tools')} - aria-current={$page.url.pathname.startsWith('/tools') ? 'page' : undefined} - > - Tools - </a> - <a - href="/hooks" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/hooks')} - aria-current={$page.url.pathname.startsWith('/hooks') ? 'page' : undefined} - > - Hooks - </a> - <a - href="/plugins" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/plugins')} - aria-current={$page.url.pathname.startsWith('/plugins') ? 'page' : undefined} - > - Plugins - </a> - <a - href="/analytics" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/analytics')} - aria-current={$page.url.pathname.startsWith('/analytics') ? 'page' : undefined} - > - Analytics - </a> - <a - href="/archived" - class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/archived')} - aria-current={$page.url.pathname.startsWith('/archived') ? 'page' : undefined} - > - Archived - </a> + {#each NAV_ITEMS as item (item.href)} + {@const active = isActive(item.href, currentPath)} + {@const Icon = item.icon} + <a + href={item.href} + title={item.label} + aria-label={item.label} + aria-current={active ? 'page' : undefined} + class="relative inline-flex items-center gap-1.5 rounded-lg transition-colors focus-ring + {active + ? 'px-2.5 py-1.5' + : 'p-2 text-[var(--text-muted)] hover:text-[var(--text-primary)]'}" + style={active + ? `background-color: var(--nav-${item.color}-subtle); color: var(--nav-${item.color});` + : ''} + > + <Icon class="size-4" /> + {#if active} + <span + class="text-[12.5px] font-medium text-[var(--text-primary)] whitespace-nowrap" + > + {item.label} + </span> + {/if} + </a> + {/each} </nav> <div class="flex items-center justify-end gap-3"> @@ -173,6 +167,8 @@ href="/settings" class="p-2 rounded-lg hover:bg-[var(--bg-muted)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]" title="Settings" + aria-label="Settings" + aria-current={currentPath.startsWith('/settings') ? 'page' : undefined} > <Settings size={18} strokeWidth={2} /> </a> @@ -201,116 +197,28 @@ role="presentation" > <nav class="flex flex-col p-6 gap-1" aria-label="Mobile navigation"> - <a - href="/projects" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/projects')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/projects')} - aria-current={$page.url.pathname.startsWith('/projects') ? 'page' : undefined} - > - Projects - </a> - <a - href="/sessions" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/sessions')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/sessions')} - aria-current={$page.url.pathname.startsWith('/sessions') ? 'page' : undefined} - > - Sessions - </a> - <a - href="/plans" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/plans')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/plans')} - aria-current={$page.url.pathname.startsWith('/plans') ? 'page' : undefined} - > - Plans - </a> - <a - href="/agents" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/agents')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/agents')} - aria-current={$page.url.pathname.startsWith('/agents') ? 'page' : undefined} - > - Agents - </a> - <a - href="/skills" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/skills')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/skills')} - aria-current={$page.url.pathname.startsWith('/skills') ? 'page' : undefined} - > - Skills - </a> - <a - href="/commands" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/commands')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/commands')} - aria-current={$page.url.pathname.startsWith('/commands') ? 'page' : undefined} - > - Commands - </a> - <a - href="/tools" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/tools')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/tools')} - aria-current={$page.url.pathname.startsWith('/tools') ? 'page' : undefined} - > - Tools - </a> - <a - href="/hooks" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/hooks')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/hooks')} - aria-current={$page.url.pathname.startsWith('/hooks') ? 'page' : undefined} - > - Hooks - </a> - <a - href="/plugins" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/plugins')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/plugins')} - aria-current={$page.url.pathname.startsWith('/plugins') ? 'page' : undefined} - > - Plugins - </a> - <a - href="/analytics" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/analytics')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/analytics')} - aria-current={$page.url.pathname.startsWith('/analytics') ? 'page' : undefined} - > - Analytics - </a> - <a - href="/archived" - onclick={closeMobileMenu} - class="text-base font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)] py-3 px-4 rounded-lg transition-colors" - class:text-[var(--text-primary)]={$page.url.pathname.startsWith('/archived')} - class:bg-[var(--bg-subtle)]={$page.url.pathname.startsWith('/archived')} - aria-current={$page.url.pathname.startsWith('/archived') ? 'page' : undefined} - > - Archived - </a> + {#each NAV_ITEMS as item (item.href)} + {@const active = isActive(item.href, currentPath)} + {@const Icon = item.icon} + <a + href={item.href} + onclick={closeMobileMenu} + aria-current={active ? 'page' : undefined} + class="flex items-center gap-3 text-base font-medium py-3 px-4 rounded-lg transition-colors + {active + ? 'text-[var(--text-primary)]' + : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-subtle)]'}" + style={active + ? `background-color: var(--nav-${item.color}-subtle);` + : ''} + > + <Icon + class="size-5 shrink-0" + style={active ? `color: var(--nav-${item.color});` : ''} + /> + <span>{item.label}</span> + </a> + {/each} </nav> </div> {/if} diff --git a/frontend/src/lib/components/LiveSessionsSection.svelte b/frontend/src/lib/components/LiveSessionsSection.svelte index d98a2335..09f9a739 100644 --- a/frontend/src/lib/components/LiveSessionsSection.svelte +++ b/frontend/src/lib/components/LiveSessionsSection.svelte @@ -7,6 +7,7 @@ LiveSubStatus, SessionSummary } from '$lib/api-types'; + import { projectHrefFromSession } from '$lib/utils/project-url'; import { statusConfig } from '$lib/live-session-config'; import { API_BASE } from '$lib/config'; @@ -299,7 +300,7 @@ return '#'; } const identifier = session.slug || session.session_id.slice(0, 8); - return `/projects/${session.project_slug || session.project_encoded_name}/${identifier}`; + return projectHrefFromSession(session, `/${identifier}`); } function canNavigate(session: LiveSessionSummary): boolean { diff --git a/frontend/src/lib/components/LiveSessionsTerminal.svelte b/frontend/src/lib/components/LiveSessionsTerminal.svelte index 73d6f956..7a9a481c 100644 --- a/frontend/src/lib/components/LiveSessionsTerminal.svelte +++ b/frontend/src/lib/components/LiveSessionsTerminal.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { FolderOpen, Bot, Trash2 } from 'lucide-svelte'; import type { LiveSessionSummary } from '$lib/api-types'; + import { projectHrefFromSession } from '$lib/utils/project-url'; import { statusConfig } from '$lib/live-session-config'; import { API_BASE } from '$lib/config'; @@ -121,7 +122,7 @@ return '#'; // Can't link without project } const identifier = session.session_id.slice(0, 8); - return `/projects/${session.project_slug || session.project_encoded_name}/${identifier}`; + return projectHrefFromSession(session, `/${identifier}`); } function canNavigate(session: LiveSessionSummary): boolean { diff --git a/frontend/src/lib/components/NavigationCard.svelte b/frontend/src/lib/components/NavigationCard.svelte index 6320718b..936ea3eb 100644 --- a/frontend/src/lib/components/NavigationCard.svelte +++ b/frontend/src/lib/components/NavigationCard.svelte @@ -17,7 +17,8 @@ | 'teal' | 'violet' | 'indigo' - | 'amber'; + | 'amber' + | 'cyan'; disabled?: boolean; } @@ -119,6 +120,14 @@ gradient: 'linear-gradient(135deg, var(--nav-amber-subtle) 0%, rgba(217, 119, 6, 0.15) 100%)', glow: '0 4px 20px -2px rgba(217, 119, 6, 0.25)' + }, + cyan: { + text: 'var(--nav-cyan)', + bg: 'var(--nav-cyan-subtle)', + border: 'var(--nav-cyan)', + gradient: + 'linear-gradient(135deg, var(--nav-cyan-subtle) 0%, rgba(14, 116, 144, 0.15) 100%)', + glow: '0 4px 20px -2px rgba(14, 116, 144, 0.25)' } }; diff --git a/frontend/src/lib/components/command-palette/CommandPalette.svelte b/frontend/src/lib/components/command-palette/CommandPalette.svelte index 7dadcbdb..c88faefd 100644 --- a/frontend/src/lib/components/command-palette/CommandPalette.svelte +++ b/frontend/src/lib/components/command-palette/CommandPalette.svelte @@ -23,6 +23,7 @@ import { commandPalette } from '$lib/stores/commandPalette'; import KeyIndicator from '$lib/components/ui/KeyIndicator.svelte'; import type { Project } from '$lib/api-types'; + import { projectHref } from '$lib/utils/project-url'; import { API_BASE } from '$lib/config'; interface Props { @@ -521,19 +522,18 @@ {#each sessions as session} <Command.Item value={`${getSessionLabel(session)} ${session.initial_prompt || ''} ${session.project_name}`} - onSelect={() => - handleSelect( - () => - goto( - `/projects/${session.project_slug}/${session.slug}` - ), - { - id: `session-${session.slug}`, - label: getSessionLabel(session), - path: `/projects/${session.project_slug}/${session.slug}`, - icon: 'session' - } - )} + onSelect={() => { + const href = projectHref( + { slug: session.project_slug }, + `/${session.slug}` + ); + handleSelect(() => goto(href), { + id: `session-${session.slug}`, + label: getSessionLabel(session), + path: href, + icon: 'session' + }); + }} class="cmd-item" > <MessageSquare size={18} class="cmd-icon" /> diff --git a/frontend/src/lib/components/conversation/ConversationOverview.svelte b/frontend/src/lib/components/conversation/ConversationOverview.svelte index 3653573c..f7ba294c 100644 --- a/frontend/src/lib/components/conversation/ConversationOverview.svelte +++ b/frontend/src/lib/components/conversation/ConversationOverview.svelte @@ -28,6 +28,7 @@ CompactionSummary } from '$lib/api-types'; import { isSubagentSession, isMainSession } from '$lib/api-types'; + import { projectHrefFromSession } from '$lib/utils/project-url'; import { formatDuration, formatTokens } from '$lib/utils'; import { API_BASE } from '$lib/config'; @@ -140,10 +141,10 @@ {:else if continuationSession} <!-- Always use UUID for continuation links - they share slugs with parent session --> <a - href="/projects/{continuationSession.project_encoded_name}/{continuationSession.session_uuid.slice( - 0, - 8 - )}" + href={projectHrefFromSession( + continuationSession, + `/${continuationSession.session_uuid.slice(0, 8)}` + )} class=" mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 diff --git a/frontend/src/lib/components/conversation/ConversationView.svelte b/frontend/src/lib/components/conversation/ConversationView.svelte index 29ef07f3..cffda66c 100644 --- a/frontend/src/lib/components/conversation/ConversationView.svelte +++ b/frontend/src/lib/components/conversation/ConversationView.svelte @@ -22,6 +22,7 @@ RefreshCw, Zap, TerminalSquare, + Ticket as TicketIcon, Search } from 'lucide-svelte'; import TabsTrigger from '$lib/components/ui/TabsTrigger.svelte'; @@ -39,6 +40,7 @@ import { PlanViewer } from '$lib/components/plan'; import SkillsPanel from '$lib/components/skills/SkillsPanel.svelte'; import CommandsPanel from '$lib/components/commands/CommandsPanel.svelte'; + import { SessionTicketsSection } from '$lib/components/tickets'; import ConversationHeader from './ConversationHeader.svelte'; import ConversationOverview from './ConversationOverview.svelte'; import type { @@ -56,7 +58,8 @@ LiveSessionSummary, LiveSessionStatus, Task, - PlanDetail + PlanDetail, + SessionTicketRow } from '$lib/api-types'; import { isSubagentSession, isMainSession } from '$lib/api-types'; import { @@ -94,6 +97,8 @@ tasks?: Task[]; /** Pre-loaded plan data (optional, may be null if no plan exists) */ plan?: PlanDetail | null; + /** Pre-loaded tickets linked to this session (Tickets tab seed). */ + tickets?: SessionTicketRow[]; } let { @@ -109,7 +114,8 @@ fileActivity: initialFileActivity = [], tools: initialTools = [], tasks: initialTasks = [], - plan = null + plan = null, + tickets = [] }: Props = $props(); // Helper functions to compute initial values from props or entity @@ -608,6 +614,8 @@ // Tab state - dynamic based on plan presence and skills // Plan appears at position 2 (after overview) when it exists + // Tickets sits before analytics for main sessions, matching the + // project-page tab pattern (Memory · Tickets · Analytics). let validTabs = $derived.by(() => { const base: string[] = ['overview']; if (plan) base.push('plan'); @@ -616,6 +624,7 @@ base.push('agents'); if (skillsArray.length > 0) base.push('skills'); if (commandsArray.length > 0) base.push('commands'); + base.push('tickets'); } base.push('analytics'); return base; @@ -975,6 +984,14 @@ > </TabsTrigger> {/if} + <TabsTrigger value="tickets" icon={TicketIcon}> + Tickets + {#if tickets.length > 0} + <span class="text-xs font-mono text-[var(--text-muted)]" + >{tickets.length}</span + > + {/if} + </TabsTrigger> {/if} <TabsTrigger value="analytics" icon={BarChart3}>Analytics</TabsTrigger> </Tabs.List> @@ -1162,6 +1179,22 @@ /> </Tabs.Content> {/if} + + <!-- Tickets Tab (parity with project page tab; same component + that previously sat above ConversationView). --> + <Tabs.Content value="tickets" class="animate-fade-in"> + {#if sessionUuid} + <SessionTicketsSection + {sessionUuid} + {sessionSlug} + initial={tickets} + /> + {:else} + <p class="text-sm text-[var(--text-muted)] m-0 px-1 py-6 text-center"> + Session UUID unavailable. + </p> + {/if} + </Tabs.Content> {/if} <!-- Analytics Tab --> diff --git a/frontend/src/lib/components/tickets/ProjectTicketsTab.svelte b/frontend/src/lib/components/tickets/ProjectTicketsTab.svelte new file mode 100644 index 00000000..0df081dd --- /dev/null +++ b/frontend/src/lib/components/tickets/ProjectTicketsTab.svelte @@ -0,0 +1,172 @@ +<script lang="ts"> + import type { TicketListItem } from '$lib/api-types'; + import { API_BASE } from '$lib/config'; + import { + normalizeStatus, + statusColorVar, + formatRelative + } from '$lib/ticket-helpers'; + import { ExternalLink, Search } from 'lucide-svelte'; + import ProviderChip from './ProviderChip.svelte'; + import TicketEmptyState from './TicketEmptyState.svelte'; + + interface Props { + projectEncodedName: string; + } + + let { projectEncodedName }: Props = $props(); + + let tickets = $state<TicketListItem[] | null>(null); + let error = $state<string | null>(null); + let q = $state(''); + + $effect(() => { + const encoded = projectEncodedName; + if (!encoded) return; + + let cancelled = false; + tickets = null; + error = null; + (async () => { + try { + const res = await fetch( + `${API_BASE}/tickets?project=${encodeURIComponent(encoded)}` + ); + if (cancelled) return; + if (!res.ok) { + error = `HTTP ${res.status}`; + return; + } + const data = (await res.json()) as TicketListItem[]; + if (!cancelled) tickets = data; + } catch (e) { + if (!cancelled) error = e instanceof Error ? e.message : String(e); + } + })(); + return () => { + cancelled = true; + }; + }); + + let filtered = $derived.by<TicketListItem[]>(() => { + const all = tickets ?? []; + const needle = q.trim().toLowerCase(); + if (!needle) return all; + return all.filter((t) => { + if (t.external_key.toLowerCase().includes(needle)) return true; + if (t.title && t.title.toLowerCase().includes(needle)) return true; + return false; + }); + }); + +</script> + +<div class="flex flex-col gap-4"> + <header class="flex items-baseline justify-between gap-3"> + <p class="text-xs text-[var(--text-muted)] m-0"> + Tickets touched by any session in this project + {#if tickets} + <span class="font-mono text-[var(--text-faint)]"> + · {filtered.length}{q && tickets.length !== filtered.length ? ` of ${tickets.length}` : ''} + </span> + {/if} + </p> + <a + href="/tickets?project={encodeURIComponent(projectEncodedName)}" + class="text-xs text-[var(--text-secondary)] hover:text-[var(--accent)] inline-flex items-center gap-1" + > + View all + <ExternalLink size={11} /> + </a> + </header> + + {#if error} + <p class="text-xs text-[var(--error)] m-0">Couldn't load tickets: {error}</p> + {:else if tickets === null} + <p class="text-xs text-[var(--text-muted)] m-0">Loading…</p> + {:else if tickets.length === 0} + <TicketEmptyState scope="project" /> + {:else} + <!-- Search + populated table --> + <form onsubmit={(e) => e.preventDefault()} class="flex items-center gap-2 max-w-[360px]"> + <div class="relative flex-1"> + <Search size={12} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" /> + <input + type="search" + placeholder="Search title or key…" + bind:value={q} + class="w-full pl-7 pr-3 py-1.5 text-xs rounded-md border border-[var(--border)] bg-[var(--bg-base)] focus-ring" + /> + </div> + </form> + + <div class="rounded-lg border border-[var(--border)] overflow-hidden bg-[var(--bg-base)]"> + <div + class="grid gap-3.5 px-4 py-2.5 bg-[var(--bg-subtle)] border-b border-[var(--border)]" + style="grid-template-columns: 80px minmax(0, 1fr) 130px 90px 110px" + > + <div class="text-[9px] uppercase tracking-wider font-semibold text-[var(--text-muted)]">Provider</div> + <div class="text-[9px] uppercase tracking-wider font-semibold text-[var(--text-muted)]">Ticket</div> + <div class="text-[9px] uppercase tracking-wider font-semibold text-[var(--text-muted)]">Status</div> + <div class="text-[9px] uppercase tracking-wider font-semibold text-[var(--text-muted)] text-right">Sessions</div> + <div class="text-[9px] uppercase tracking-wider font-semibold text-[var(--text-muted)]">Last linked</div> + </div> + + {#each filtered as t (t.id)} + {@const norm = normalizeStatus(t.status)} + <a + href="/tickets/{t.provider}/{encodeURIComponent(t.external_key)}" + class="grid gap-3.5 px-4 py-3 items-center border-t border-[var(--border-subtle)] hover:bg-[var(--accent-muted)] transition-colors" + style="grid-template-columns: 80px minmax(0, 1fr) 130px 90px 110px" + > + <span class="justify-self-start"> + <ProviderChip ticket={t} /> + </span> + + <div class="min-w-0"> + <div class="flex items-center gap-1.5 text-[var(--text-primary)]"> + <span class="font-mono text-xs whitespace-nowrap overflow-hidden text-ellipsis">{t.external_key}</span> + <ExternalLink size={10} class="text-[var(--text-faint)] shrink-0" /> + </div> + {#if t.title} + <div class="text-xs text-[var(--text-secondary)] mt-0.5 truncate">{t.title}</div> + {:else} + <div class="text-[11px] text-[var(--text-faint)] mt-0.5 italic">title not yet fetched</div> + {/if} + </div> + + <div class="inline-flex items-center gap-1.5 text-[11.5px] text-[var(--text-secondary)]"> + {#if t.status} + <span + class="inline-block w-[7px] h-[7px] rounded-full shrink-0" + style="background: var({statusColorVar(norm.key)})" + ></span> + {t.status} + {:else} + <span class="text-[var(--text-faint)]">—</span> + {/if} + </div> + + <div class="text-right"> + <span + class="inline-flex font-mono text-[11px] px-2 py-0.5 rounded-full + {t.session_count > 1 + ? 'bg-[var(--accent-subtle)] text-[var(--accent)]' + : 'bg-[var(--bg-muted)] text-[var(--text-secondary)]'}" + > + {t.session_count} + </span> + </div> + + <div class="text-[11px] text-[var(--text-muted)]">{formatRelative(t.last_linked_at)}</div> + </a> + {/each} + + {#if filtered.length === 0} + <div class="px-4 py-6 text-center text-xs text-[var(--text-muted)]"> + No tickets match your search. + </div> + {/if} + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/tickets/ProviderChip.svelte b/frontend/src/lib/components/tickets/ProviderChip.svelte new file mode 100644 index 00000000..41c79156 --- /dev/null +++ b/frontend/src/lib/components/tickets/ProviderChip.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + /** + * Provider letter-mark chip (LIN / JIR / GH) with optional GitHub + * kind indicator (issue vs pull request). + * + * Extracted so every surface that renders a ticket — TicketBadge, + * tickets index, project Tickets tab, ticket detail header — agrees + * on the same visual treatment. Without this, the PR indicator would + * have to be re-implemented at each callsite or silently omitted at + * some of them (the original bug surface). + */ + import type { Ticket } from '$lib/api-types'; + import { PROVIDER_META, githubKindFromUrl } from '$lib/ticket-helpers'; + import { GitPullRequest } from 'lucide-svelte'; + + interface Props { + ticket: Pick<Ticket, 'provider' | 'url'>; + /** Render the PR pip after the chip when applicable. Default true. */ + showKind?: boolean; + } + + let { ticket, showKind = true }: Props = $props(); + + let meta = $derived(PROVIDER_META[ticket.provider]); + let isPullRequest = $derived( + ticket.provider === 'github' && githubKindFromUrl(ticket.url) === 'pull_request' + ); +</script> + +<span class="inline-flex items-center gap-1 shrink-0"> + <span + class="inline-flex items-center font-mono font-bold px-1 py-[1px] rounded-sm text-[10px] tracking-wider leading-snug" + style="background: var({meta.colorVar}); color: var({meta.fgVar})" + title={meta.label} + aria-label={meta.label} + > + {meta.short} + </span> + {#if showKind && isPullRequest} + <!-- Pull-request indicator. The icon is GitHub's own glyph; the + monospace " PR " is for skim-readers who don't recognize it. --> + <span + class="inline-flex items-center gap-0.5 font-mono text-[9.5px] tracking-wider font-semibold uppercase text-[var(--text-muted)] leading-snug" + title="Pull request" + aria-label="Pull request" + > + <GitPullRequest size={10} aria-hidden="true" /> + PR + </span> + {/if} +</span> diff --git a/frontend/src/lib/components/tickets/SessionTicketsSection.svelte b/frontend/src/lib/components/tickets/SessionTicketsSection.svelte new file mode 100644 index 00000000..79798896 --- /dev/null +++ b/frontend/src/lib/components/tickets/SessionTicketsSection.svelte @@ -0,0 +1,178 @@ +<script lang="ts"> + import type { CreateLinkResponse, SessionTicketRow } from '$lib/api-types'; + import { API_BASE } from '$lib/config'; + import { Ticket as TicketIcon, Undo2, Plus } from 'lucide-svelte'; + import TicketBadge from './TicketBadge.svelte'; + import TicketLinkInput from './TicketLinkInput.svelte'; + import { onDestroy } from 'svelte'; + + interface Props { + sessionUuid: string; + sessionSlug?: string | null; + /** Initial tickets fetched server-side. Component holds local state from here. */ + initial?: SessionTicketRow[]; + } + + let { sessionUuid, sessionSlug, initial = [] }: Props = $props(); + + // Seed once at construction. Route changes unmount the component so we don't + // need to react to `initial` changing. + let tickets = $state<SessionTicketRow[]>($state.snapshot(initial)); + + // Undo toast state — keyed by linkId so we never overlap. + type PendingUndo = { + ticket: SessionTicketRow; + expiresAt: number; + timeoutId: ReturnType<typeof setTimeout>; + }; + let pending = $state<PendingUndo | null>(null); + const UNDO_MS = 5000; + let tick = $state(0); + let countdownInterval: ReturnType<typeof setInterval> | null = null; + + function startCountdown() { + if (countdownInterval) return; + countdownInterval = setInterval(() => { + tick++; + if (!pending || pending.expiresAt <= Date.now()) { + stopCountdown(); + } + }, 250); + } + function stopCountdown() { + if (countdownInterval) clearInterval(countdownInterval); + countdownInterval = null; + } + onDestroy(stopCountdown); + + let secondsLeft = $derived.by(() => { + // reference tick so this recomputes + void tick; + if (!pending) return 0; + return Math.max(0, Math.ceil((pending.expiresAt - Date.now()) / 1000)); + }); + + function handleCreated(resp: CreateLinkResponse) { + const next: SessionTicketRow = { + ...resp.ticket, + link_id: resp.link.id, + link_source: resp.link.link_source, + linked_at: resp.link.linked_at, + session_slug: resp.link.session_slug + }; + const idx = tickets.findIndex((t) => t.id === resp.ticket.id); + if (idx >= 0) tickets[idx] = next; + else tickets = [next, ...tickets]; + } + + function requestUnlink(ticket: SessionTicketRow) { + // Optimistic remove + tickets = tickets.filter((t) => t.id !== ticket.id); + + // If there's already a pending undo, commit it immediately (we only show one toast) + if (pending) { + clearTimeout(pending.timeoutId); + void commitUnlink(pending.ticket); + } + + const timeoutId = setTimeout(() => { + void commitUnlink(ticket); + pending = null; + stopCountdown(); + }, UNDO_MS); + + pending = { ticket, expiresAt: Date.now() + UNDO_MS, timeoutId }; + startCountdown(); + } + + function undoUnlink() { + if (!pending) return; + clearTimeout(pending.timeoutId); + // Restore the ticket at the top (most recent linked-at) + tickets = [pending.ticket, ...tickets]; + pending = null; + stopCountdown(); + } + + async function commitUnlink(ticket: SessionTicketRow) { + try { + const res = await fetch(`${API_BASE}/sessions/${sessionUuid}/tickets/${ticket.id}`, { + method: 'DELETE' + }); + if (!res.ok) { + // Restore on failure + tickets = [ticket, ...tickets]; + } + } catch { + tickets = [ticket, ...tickets]; + } + } +</script> + +<section + class="flex flex-col gap-2.5 px-4 py-3 rounded-lg border border-[var(--border)] bg-[var(--bg-base)]" + aria-labelledby="tickets-heading" +> + <header class="flex items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <TicketIcon size={13} class="text-[var(--text-muted)]" /> + <span + id="tickets-heading" + class="font-mono text-[12px] text-[var(--accent)]" + > + $ tickets + </span> + <span class="font-mono text-[11px] text-[var(--text-faint)]"> + [{tickets.length} linked] + </span> + </div> + </header> + + {#if tickets.length > 0} + <ul class="flex flex-wrap gap-1.5 m-0 p-0 list-none"> + {#each tickets as ticket (ticket.id)} + <li> + <TicketBadge + {ticket} + variant="pill" + showStatus={true} + onRemove={() => requestUnlink(ticket)} + /> + </li> + {/each} + </ul> + {/if} + + {#if pending} + <div + class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-[var(--bg-muted)] border border-dashed border-[var(--border)] text-[11px] text-[var(--text-muted)] self-start" + role="status" + aria-live="polite" + > + <Undo2 size={11} /> + <span> + Unlinked + <code class="font-mono">{pending.ticket.external_key}</code> + </span> + <span class="text-[var(--text-faint)]">· undo in {secondsLeft}s</span> + <button + type="button" + onclick={undoUnlink} + class="text-[var(--accent)] hover:underline font-semibold focus-ring" + > + Undo + </button> + </div> + {/if} + + {#if tickets.length === 0 && !pending} + <p class="text-[11px] text-[var(--text-muted)] m-0 inline-flex items-center gap-1.5"> + <Plus size={10} /> + Paste a URL, key, or + <code class="font-mono px-1 py-px rounded bg-[var(--bg-muted)] text-[var(--text-secondary)]">owner/repo#N</code> + below to link this session. + </p> + {/if} + + <TicketLinkInput {sessionUuid} {sessionSlug} onCreated={handleCreated} /> +</section> diff --git a/frontend/src/lib/components/tickets/TicketBadge.svelte b/frontend/src/lib/components/tickets/TicketBadge.svelte new file mode 100644 index 00000000..8f751773 --- /dev/null +++ b/frontend/src/lib/components/tickets/TicketBadge.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import type { Ticket } from '$lib/api-types'; + import { + PROVIDER_META, + normalizeStatus, + statusColorVar + } from '$lib/ticket-helpers'; + import { ExternalLink, X, MoreHorizontal, Hash, Copy } from 'lucide-svelte'; + import ProviderChip from './ProviderChip.svelte'; + + type Variant = 'inline' | 'card' | 'pill'; + + interface Props { + ticket: Pick<Ticket, 'provider' | 'external_key' | 'url' | 'title' | 'status'>; + variant?: Variant; + /** + * If provided, the pill/card variant gets a kebab menu with Open / Copy / + * Unlink. Omit on read-only contexts (table cells, detail header). + */ + onRemove?: () => void; + /** Hide the trailing status dot+label when the surrounding context already shows it. */ + showStatus?: boolean; + } + + let { ticket, variant = 'pill', onRemove, showStatus = true }: Props = $props(); + + let meta = $derived(PROVIDER_META[ticket.provider]); + let norm = $derived(normalizeStatus(ticket.status)); + let menuOpen = $state(false); + let menuEl: HTMLSpanElement | null = $state(null); + + function copyKey() { + void navigator.clipboard.writeText(ticket.external_key); + menuOpen = false; + } + + function openProvider() { + window.open(ticket.url, '_blank', 'noopener,noreferrer'); + menuOpen = false; + } + + function unlink() { + menuOpen = false; + onRemove?.(); + } + + // Outside-click close + function handleDocClick(e: MouseEvent) { + if (!menuOpen) return; + if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false; + } + + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape' && menuOpen) menuOpen = false; + } +</script> + +<svelte:document onclick={handleDocClick} onkeydown={handleKey} /> + +{#snippet statusDot(size = 6)} + <span + aria-hidden="true" + class="inline-block rounded-full shrink-0" + style="width: {size}px; height: {size}px; background: var({statusColorVar(norm.key)})" + ></span> +{/snippet} + +{#if variant === 'card'} + <div class="flex flex-col gap-2 p-4 rounded-lg border border-[var(--border)] bg-[var(--bg-subtle)]"> + <div class="flex items-center justify-between gap-3"> + <div class="flex items-center gap-2 min-w-0"> + <ProviderChip {ticket} /> + <a + href={ticket.url} + target="_blank" + rel="noopener noreferrer" + class="font-mono text-sm text-[var(--text-primary)] hover:text-[var(--accent)] inline-flex items-center gap-1 truncate" + > + <span class="truncate">{ticket.external_key}</span> + <ExternalLink size={12} class="shrink-0" /> + </a> + </div> + {#if onRemove} + <button + type="button" + onclick={unlink} + class="text-[var(--text-muted)] hover:text-[var(--error)] p-1 rounded transition-colors focus-ring" + aria-label="Unlink ticket" + title="Unlink ticket" + > + <X size={14} /> + </button> + {/if} + </div> + {#if ticket.title} + <p class="text-sm text-[var(--text-primary)] leading-snug m-0">{ticket.title}</p> + {/if} + {#if showStatus && norm.verbatim} + <span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)]"> + {@render statusDot(7)} + {norm.verbatim} + </span> + {/if} + </div> + +{:else if variant === 'inline'} + <span class="inline-flex items-center gap-1.5 text-sm min-w-0"> + <ProviderChip {ticket} /> + <span class="font-mono text-[var(--text-primary)] whitespace-nowrap"> + {ticket.external_key} + </span> + {#if ticket.title} + <span class="text-[var(--text-secondary)] truncate max-w-[36ch]">— {ticket.title}</span> + {/if} + </span> + +{:else} + <!-- pill — provider chip + key + optional status + kebab. Title lives in + the `title` tooltip; the kebab provides "Open in {provider}" so the + external-link icon next to the key is redundant when onRemove is set. --> + <span + class="inline-flex items-center gap-1.5 pl-1.5 pr-2 py-[3px] rounded-full text-xs border border-[var(--border)] bg-[var(--bg-base)]" + title={ticket.title ?? undefined} + > + <ProviderChip {ticket} /> + <a + href={ticket.url} + target="_blank" + rel="noopener noreferrer" + class="font-mono text-[11.5px] text-[var(--text-primary)] hover:text-[var(--accent)] inline-flex items-center gap-1 hover:underline whitespace-nowrap" + > + {ticket.external_key} + {#if !onRemove} + <ExternalLink size={9} /> + {/if} + </a> + {#if showStatus && norm.verbatim} + <span class="inline-flex items-center gap-1 pl-1.5 ml-0.5 border-l border-[var(--border-subtle)] text-[11px] text-[var(--text-muted)] whitespace-nowrap"> + {@render statusDot(6)} + {norm.verbatim} + </span> + {/if} + {#if onRemove} + <span class="relative inline-flex" bind:this={menuEl}> + <button + type="button" + onclick={(e) => { + e.stopPropagation(); + menuOpen = !menuOpen; + }} + class="-mr-1 ml-0.5 p-0.5 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-muted)] transition-colors focus-ring" + aria-label="Ticket options" + aria-haspopup="menu" + aria-expanded={menuOpen} + > + <MoreHorizontal size={12} /> + </button> + {#if menuOpen} + <div + class="absolute right-0 top-full mt-1.5 z-20 min-w-[160px] p-1 rounded-md border border-[var(--border)] bg-[var(--bg-base)] shadow-[var(--shadow-md)]" + role="menu" + > + <button + type="button" + onclick={openProvider} + class="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-[var(--text-primary)] rounded hover:bg-[var(--bg-subtle)] text-left" + role="menuitem" + > + <ExternalLink size={12} /> Open in {meta.label} + </button> + <button + type="button" + onclick={copyKey} + class="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-[var(--text-primary)] rounded hover:bg-[var(--bg-subtle)] text-left" + role="menuitem" + > + <Copy size={12} /> Copy key + </button> + <div class="my-1 h-px bg-[var(--border-subtle)]"></div> + <button + type="button" + onclick={unlink} + class="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-[var(--error)] rounded hover:bg-[var(--error-subtle)] text-left" + role="menuitem" + > + <X size={12} /> Unlink + </button> + </div> + {/if} + </span> + {/if} + </span> +{/if} diff --git a/frontend/src/lib/components/tickets/TicketEmptyState.svelte b/frontend/src/lib/components/tickets/TicketEmptyState.svelte new file mode 100644 index 00000000..0af0a886 --- /dev/null +++ b/frontend/src/lib/components/tickets/TicketEmptyState.svelte @@ -0,0 +1,170 @@ +<script lang="ts"> + /** + * Empty state for ticket surfaces — shown on `/tickets` (global) and on + * each project's Tickets tab when zero tickets are linked. + * + * Three "ways to start" cards, ordered by friction (least → most): + * 1. Dashboard paste — works out of the box, no install + * 2. Slash command — needs the link-ticket-to-session skill installed + * 3. Branch hook — opt-in, requires hook + config (Tier 4) + * + * Card #2 carries an inline "install once" block with the symlink and + * cp variants from SETUP.md → Tier 4. Lying about the install state + * ("installed by default") was a real bug that made first-time users + * type `/link-ticket-to-session` and get nothing — see the v0.2.0 + * release notes for context. + */ + import { ExternalLink, GitBranch, Link as LinkIcon, Slash, Sparkles } from 'lucide-svelte'; + + interface Props { + /** Adjusts copy slightly. 'global' = /tickets index. 'project' = ProjectTicketsTab. */ + scope?: 'global' | 'project'; + } + + let { scope = 'global' }: Props = $props(); + + const pasteSub = + scope === 'project' + ? 'Open any session in this project and use the Tickets section.' + : 'Open the session, scroll to the Tickets section, paste the URL.'; + + const LINK_PATHS = [ + { + n: '01', + title: 'Paste a URL from any session page', + cmd: 'https://linear.app/team/issue/ABC-123', + sub: pasteSub, + badge: 'works out of the box', + Icon: LinkIcon, + install: null as null | { heading: string; commands: readonly string[] } + }, + { + n: '02', + title: 'In a session — type a slash command', + cmd: '/link-ticket-to-session ABC-123', + sub: 'Agent fetches title via your Linear / Atlassian / GitHub MCP if installed.', + badge: 'one-time setup', + Icon: Slash, + install: { + heading: 'Install once from the karma repo:', + commands: [ + 'ln -sf "$PWD/skills/link-ticket-to-session" ~/.claude/skills/', + '# or: cp -R skills/link-ticket-to-session ~/.claude/skills/' + ] + } + }, + { + n: '03', + title: 'Push a branch that names the ticket', + cmd: 'git checkout -b feat/ABC-123-…', + sub: 'Configure patterns in ~/.claude_karma/config.json. See SETUP.md → Tier 4.', + badge: 'opt-in hook', + Icon: GitBranch, + install: null as null | { heading: string; commands: readonly string[] } + } + ] as const; + + const headline = + scope === 'project' + ? 'Nothing linked here yet. Three ways to start.' + : 'No tickets yet. Three ways to start linking.'; + + const subhead = + scope === 'project' + ? 'When a session in this project gets linked to a ticket, it shows up here.' + : 'Karma links sessions to tickets in Linear, Jira, or GitHub Issues.'; + + const headerSuffix = scope === 'project' ? '[0 linked in this project]' : '[0 linked]'; +</script> + +<div + class="w-full p-6 rounded-lg border border-dashed border-[var(--border)] bg-[var(--bg-subtle)] flex flex-col gap-5" +> + <div class="flex items-center gap-2.5"> + <span class="font-mono text-sm text-[var(--accent)]">$ tickets</span> + <span class="font-mono text-sm text-[var(--text-faint)]">{headerSuffix}</span> + </div> + + <div> + <h3 class="text-lg font-semibold text-[var(--text-primary)] m-0 leading-tight"> + {headline} + </h3> + <p class="text-sm text-[var(--text-muted)] mt-1 mb-0 max-w-[60ch]">{subhead}</p> + </div> + + <div + class="flex flex-col rounded-md border border-[var(--border)] bg-[var(--bg-base)] overflow-hidden" + > + {#each LINK_PATHS as row, i (row.n)} + <div + class="flex flex-col" + class:border-t={i > 0} + class:border-[var(--border-subtle)]={i > 0} + > + <!-- Main row: number · title+sub · primary command --> + <div + class="grid items-center gap-4 px-4 py-3" + style="grid-template-columns: 28px 1fr auto" + > + <span class="font-mono text-xs text-[var(--text-faint)]">{row.n}</span> + <div> + <div + class="text-sm font-medium text-[var(--text-primary)] flex items-center gap-2 flex-wrap" + > + <row.Icon size={12} class="text-[var(--text-muted)]" /> + {row.title} + <span + class="font-mono text-[9.5px] uppercase tracking-wider px-1.5 py-[1px] rounded-sm bg-[var(--bg-muted)] text-[var(--text-muted)]" + > + {row.badge} + </span> + </div> + <p class="text-xs text-[var(--text-muted)] mt-0.5 mb-0">{row.sub}</p> + </div> + <code + class="font-mono text-[11.5px] px-2.5 py-1.5 rounded bg-[var(--bg-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] whitespace-nowrap" + > + {row.cmd} + </code> + </div> + + <!-- Optional install block: full-width under the main row --> + {#if row.install} + <div + class="px-4 pb-3 pt-0 grid gap-2" + style="grid-template-columns: 28px 1fr; padding-left: calc(1rem + 28px + 1rem);" + > + <div class="col-span-2 flex flex-col gap-1.5"> + <p class="text-[11px] text-[var(--text-muted)] m-0">{row.install.heading}</p> + <div class="flex flex-col gap-1"> + {#each row.install.commands as command} + <code + class="font-mono text-[11px] px-2.5 py-1.5 rounded bg-[var(--bg-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] whitespace-pre-wrap break-all" + > + {command} + </code> + {/each} + </div> + </div> + </div> + {/if} + </div> + {/each} + </div> + + <div class="flex items-center justify-between gap-2 text-[11px] text-[var(--text-faint)]"> + <span class="flex items-center gap-2"> + <Sparkles size={11} /> + Karma is read-only — the ticket lives in its source of truth. + </span> + <a + href="https://github.com/JayantDevkar/claude-code-karma/blob/main/SETUP.md#tier-4-auto-link-tickets-optional" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-1 text-[var(--text-muted)] hover:text-[var(--accent)]" + > + SETUP.md → Tier 4 + <ExternalLink size={10} /> + </a> + </div> +</div> diff --git a/frontend/src/lib/components/tickets/TicketLinkInput.svelte b/frontend/src/lib/components/tickets/TicketLinkInput.svelte new file mode 100644 index 00000000..39219758 --- /dev/null +++ b/frontend/src/lib/components/tickets/TicketLinkInput.svelte @@ -0,0 +1,231 @@ +<script lang="ts"> + import type { CreateLinkRequest, CreateLinkResponse, TicketProvider } from '$lib/api-types'; + import { API_BASE } from '$lib/config'; + import { + PROVIDER_META, + detectProviderFromRef, + isAmbiguousKey + } from '$lib/ticket-helpers'; + import { + Plus, + Loader2, + Check, + AlertCircle, + Sparkles, + ChevronDown + } from 'lucide-svelte'; + + interface Props { + sessionUuid: string; + sessionSlug?: string | null; + onCreated?: (response: CreateLinkResponse) => void; + } + + let { sessionUuid, sessionSlug, onCreated }: Props = $props(); + + type State = 'idle' | 'typing' | 'validating' | 'error' | 'success'; + + let ref = $state(''); + let providerHint = $state<TicketProvider | ''>(''); + let phase = $state<State>('idle'); + let error = $state<{ headline: string; hint: string | null } | null>(null); + + // Detection results — recomputed reactively + let trimmed = $derived(ref.trim()); + let detected = $derived(detectProviderFromRef(trimmed)); + let needsHint = $derived(!detected && isAmbiguousKey(trimmed)); + let effectiveProvider = $derived( + detected ?? (providerHint as TicketProvider | '') ?? '' + ); + + let canSubmit = $derived( + trimmed.length > 0 && + phase !== 'validating' && + phase !== 'success' && + (!needsHint || providerHint.length > 0) + ); + + function onInput(e: Event) { + const v = (e.target as HTMLInputElement).value; + ref = v; + // Any user input from a terminal phase returns us to typing/idle. + error = null; + phase = v.trim() ? 'typing' : 'idle'; + } + + async function submit(e: Event) { + e.preventDefault(); + if (!canSubmit) return; + + phase = 'validating'; + error = null; + + const body: CreateLinkRequest = { + ref: trimmed, + source: 'dashboard', + ...(sessionSlug ? { session_slug: sessionSlug } : {}), + ...(needsHint && providerHint ? { provider: providerHint as TicketProvider } : {}) + }; + + try { + const res = await fetch(`${API_BASE}/sessions/${sessionUuid}/tickets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const detail = await res.json().catch(() => null); + const hint = detail?.detail?.hint ?? detail?.detail?.error ?? null; + const headline = + typeof detail?.detail === 'string' + ? detail.detail + : detail?.detail?.error ?? `Couldn't link (HTTP ${res.status})`; + error = { headline: String(headline), hint: hint ? String(hint) : null }; + phase = 'error'; + return; + } + const data = (await res.json()) as CreateLinkResponse; + phase = 'success'; + onCreated?.(data); + // Hold the success phase briefly, then reset + setTimeout(() => { + ref = ''; + providerHint = ''; + phase = 'idle'; + }, 1200); + } catch (err) { + error = { + headline: err instanceof Error ? err.message : String(err), + hint: null + }; + phase = 'error'; + } + } +</script> + +<form onsubmit={submit} class="flex flex-col gap-1.5 w-full"> + <div class="flex items-stretch gap-2 w-full"> + <!-- Input + in-input affordance --> + <div class="relative flex-1 min-w-0"> + <input + type="text" + placeholder="Paste URL or ref (e.g. LINEAR-123, owner/repo#42)" + value={ref} + oninput={onInput} + disabled={phase === 'validating' || phase === 'success'} + class="w-full px-3 py-1.5 text-sm rounded-md bg-[var(--bg-base)] text-[var(--text-primary)] border transition-[border-color,box-shadow,background] duration-150 outline-none + {phase === 'error' + ? 'border-[var(--error)] shadow-[0_0_0_3px_var(--error-subtle)]' + : phase === 'success' + ? 'border-[var(--success)] shadow-[0_0_0_3px_var(--success-subtle)]' + : phase === 'typing' || phase === 'validating' + ? 'border-[var(--accent)] shadow-[0_0_0_3px_var(--accent-subtle)]' + : 'border-[var(--border)] hover:border-[var(--border-hover)]'} + {ref ? 'font-mono' : ''}" + /> + {#if detected && (phase === 'typing' || phase === 'validating')} + <div + class="absolute right-2.5 top-1/2 -translate-y-1/2 inline-flex items-center gap-1.5 pointer-events-none" + > + {#if phase === 'validating'} + <span + class="inline-block w-1.5 h-1.5 rounded-full animate-pulse" + style="background: var(--accent)" + ></span> + <span class="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-semibold"> + checking… + </span> + {:else} + <Check size={11} class="text-[var(--success)]" /> + <span class="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] font-semibold"> + {PROVIDER_META[detected].label} + </span> + {/if} + </div> + {/if} + </div> + + {#if needsHint} + <label class="inline-flex items-center gap-1.5 px-2 text-xs text-[var(--text-secondary)] rounded-md border border-[var(--border)] bg-[var(--bg-base)]"> + <span class="text-[10px] uppercase tracking-wider text-[var(--text-faint)] font-semibold">as</span> + <select + bind:value={providerHint} + disabled={phase === 'validating'} + class="bg-transparent text-sm py-1 pr-1 outline-none" + aria-label="Provider" + > + <option value="" disabled>provider…</option> + <option value="linear">Linear</option> + <option value="jira">Jira</option> + </select> + <ChevronDown size={11} class="text-[var(--text-muted)] -ml-1" /> + </label> + {/if} + + <button + type="submit" + disabled={!canSubmit} + class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md text-white transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed + {phase === 'success' ? 'bg-[var(--success)]' : 'bg-[var(--accent)] hover:bg-[var(--accent-hover)]'}" + > + {#if phase === 'validating'} + <Loader2 size={14} class="animate-spin" /> + Linking… + {:else if phase === 'success'} + <Check size={14} /> + Linked + {:else} + <Plus size={14} /> + Link + {/if} + </button> + </div> + + <!-- Below-input feedback row --> + {#if phase === 'idle'} + <p class="text-[11px] text-[var(--text-faint)] inline-flex items-center gap-1.5 m-0"> + <Sparkles size={10} /> + <span> + Or use + <code class="font-mono px-1 py-px rounded bg-[var(--bg-muted)] text-[var(--text-secondary)]">/link-ticket-to-session</code> + in this session, or push a branch named + <code class="font-mono px-1 py-px rounded bg-[var(--bg-muted)] text-[var(--text-secondary)]">feat/ABC-123-…</code> + </span> + </p> + {:else if phase === 'typing' && detected} + <p class="text-[11px] text-[var(--text-secondary)] inline-flex items-center gap-1.5 m-0"> + <Check size={10} class="text-[var(--success)]" /> + Recognized as <strong class="font-semibold text-[var(--text-primary)]">{PROVIDER_META[detected].label}</strong> · + press + <code class="font-mono px-1 py-px rounded bg-[var(--bg-muted)] text-[var(--text-secondary)]">↵</code> + to link + </p> + {:else if phase === 'typing' && needsHint} + <p class="text-[11px] text-[var(--text-muted)] m-0"> + Ambiguous key — pick a provider on the right. + </p> + {:else if phase === 'validating'} + <p class="text-[11px] text-[var(--text-muted)] inline-flex items-center gap-1.5 m-0"> + <span class="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style="background: var(--accent)"></span> + Fetching metadata via MCP — falls back to ref-only if unreachable. + </p> + {:else if phase === 'error' && error} + <div + class="text-[11px] text-[var(--error)] inline-flex items-start gap-1.5 px-2.5 py-1.5 rounded bg-[var(--error-subtle)] m-0" + role="alert" + > + <AlertCircle size={11} class="mt-px shrink-0" /> + <div> + <strong class="font-semibold">{error.headline}</strong> + {#if error.hint} + <div class="text-[var(--text-secondary)] mt-0.5">{error.hint}</div> + {/if} + </div> + </div> + {:else if phase === 'success'} + <p class="text-[11px] text-[var(--success)] inline-flex items-center gap-1.5 m-0"> + <Check size={11} /> + Linked. <span class="text-[var(--text-secondary)]">You can paste another, or close.</span> + </p> + {/if} +</form> diff --git a/frontend/src/lib/components/tickets/index.ts b/frontend/src/lib/components/tickets/index.ts new file mode 100644 index 00000000..51674b16 --- /dev/null +++ b/frontend/src/lib/components/tickets/index.ts @@ -0,0 +1,6 @@ +export { default as TicketBadge } from './TicketBadge.svelte'; +export { default as TicketLinkInput } from './TicketLinkInput.svelte'; +export { default as SessionTicketsSection } from './SessionTicketsSection.svelte'; +export { default as ProjectTicketsTab } from './ProjectTicketsTab.svelte'; +export { default as ProviderChip } from './ProviderChip.svelte'; +export { default as TicketEmptyState } from './TicketEmptyState.svelte'; diff --git a/frontend/src/lib/ticket-helpers.ts b/frontend/src/lib/ticket-helpers.ts new file mode 100644 index 00000000..e5bf1649 --- /dev/null +++ b/frontend/src/lib/ticket-helpers.ts @@ -0,0 +1,185 @@ +/** + * Shared visual + semantic helpers for the ticket feature. + * + * Keeps the per-provider color / letter-mark / glyph and the status + * normalization in one place so badges, inputs, the index page, and the + * detail page all agree. + */ + +import type { TicketProvider } from '$lib/api-types'; + +export interface ProviderMeta { + label: string; // "Linear" + short: string; // "LIN" — letter-mark used on the colored chip + /** CSS var name for the industry-flavored background color. */ + colorVar: string; + /** CSS var name for the same color at low alpha. */ + subtleVar: string; + /** CSS var name for the foreground (letter-mark text) color. + * Flips per-mode so chips stay AA-legible in dark mode — GitHub's silver + * bg needs dark text, Jira's lightened blue does too. */ + fgVar: string; +} + +export const PROVIDER_META: Record<TicketProvider, ProviderMeta> = { + linear: { + label: 'Linear', + short: 'LIN', + colorVar: '--provider-linear', + subtleVar: '--provider-linear-subtle', + fgVar: '--provider-linear-fg' + }, + jira: { + label: 'Jira', + short: 'JIR', + colorVar: '--provider-jira', + subtleVar: '--provider-jira-subtle', + fgVar: '--provider-jira-fg' + }, + github: { + label: 'GitHub', + short: 'GH', + colorVar: '--provider-github', + subtleVar: '--provider-github-subtle', + fgVar: '--provider-github-fg' + } +}; + +/** Normalized status keys used to pick a status-dot color. */ +export type StatusKey = + | 'todo' + | 'active' + | 'review' + | 'done' + | 'closed' + | 'unknown'; + +const STATUS_COLOR_VARS: Record<StatusKey, string> = { + todo: '--status-todo', + active: '--status-active', + review: '--status-review', + done: '--status-done', + closed: '--status-closed', + unknown: '--text-faint' +}; + +export function statusColorVar(k: StatusKey): string { + return STATUS_COLOR_VARS[k]; +} + +/** + * Map each provider's native status vocabulary onto a small shared set, + * preserving the verbatim string so the UI can render both a normalized + * dot and the original label side-by-side. + * + * Unknown / future statuses fall through to 'active' (visible signal) + * rather than 'unknown' (gray) to avoid making in-flight work look stale. + */ +export interface NormalizedStatus { + key: StatusKey; + verbatim: string | null; +} + +export function normalizeStatus(status: string | null | undefined): NormalizedStatus { + if (!status) return { key: 'unknown', verbatim: null }; + const s = status.toLowerCase().trim(); + if (s === 'open' || s === 'to do' || s === 'todo' || s === 'backlog' || s === 'triage') { + return { key: 'todo', verbatim: status }; + } + if (s === 'in progress' || s === 'doing' || s === 'in development') { + return { key: 'active', verbatim: status }; + } + if (s === 'in review' || s === 'review' || s === 'code review') { + return { key: 'review', verbatim: status }; + } + if (s === 'closed') { + return { key: 'closed', verbatim: status }; + } + if (s === 'done' || s === 'resolved' || s === 'merged') { + return { key: 'done', verbatim: status }; + } + if (s === 'canceled' || s === 'cancelled' || s === 'wontfix' || s === "won't fix") { + return { key: 'closed', verbatim: status }; + } + return { key: 'active', verbatim: status }; +} + +/** + * GitHub-specific ticket subtype derived from the stored URL. + * + * GitHub uses one numbering namespace for issues and pull requests but + * distinct URL paths (`/issues/N` vs `/pull/N`) and distinct semantics + * — PRs carry draft/merged state, issues don't. The backend stores the + * URL verbatim from the parser; the UI derives the kind from the URL + * at render time. This avoids a schema asymmetry (Linear/Jira have no + * equivalent concept) and stays accurate without an extra DB column. + * + * Returns `'pull_request'` only when the URL is unambiguously a PR + * (contains `/pull/<digits>`). Bare/unknown URLs fall through to + * `'issue'` since GitHub auto-redirects `/issues/N` to `/pull/N` when + * N is actually a PR — those rows are visually-correct-but-imprecise, + * not broken. + */ +export type GithubKind = 'issue' | 'pull_request'; + +export function githubKindFromUrl(url: string | null | undefined): GithubKind { + if (!url) return 'issue'; + // Parse to URL so the `/pull/N` check only ever looks at the pathname. + // A regex over the raw string would false-positive on URLs that happen + // to contain `/pull/<digits>` inside a query string (e.g. an issue URL + // with `?file=/pull/9`). Backend canonical URLs are stripped of query + // strings today, so this is defensive — but the regex was wider than + // the test claimed, which is a class of patchy we shouldn't keep. + try { + const path = new URL(url).pathname; + return /^\/[^/]+\/[^/]+\/pull\/\d+(?:\/|$)/i.test(path) + ? 'pull_request' + : 'issue'; + } catch { + // Malformed URL — fall back to 'issue' (the safe default per the + // docstring above). + return 'issue'; + } +} + +/** Client-side parse so the link input can auto-detect provider as the user types. */ +export function detectProviderFromRef(ref: string): TicketProvider | null { + const s = ref.trim(); + if (!s) return null; + if (/linear\.app/i.test(s)) return 'linear'; + if (/\.atlassian\.(net|com)/i.test(s)) return 'jira'; + if (/github\.com/i.test(s)) return 'github'; + if (/^[\w.-]+\/[\w.-]+#\d+$/.test(s)) return 'github'; + return null; +} + +/** True if `ref` is a bare alphanumeric key (e.g. `OCC-1284`) — needs a provider hint. */ +export function isAmbiguousKey(ref: string): boolean { + const s = ref.trim(); + return s.length > 0 && /^[A-Z][A-Z0-9_]*-\d+$/i.test(s); +} + +/** + * Best-effort relative-time formatter — matches the style used elsewhere + * in the dashboard (e.g. "9m ago", "2h ago", "3d ago"). + */ +export function formatRelative(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + const diff = Date.now() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return d.toLocaleDateString(); +} + +/** project_encoded_name → short display ("-Users-x-GitHub-claude-karma" → "claude-karma"). */ +export function projectDisplayName(encoded: string | null | undefined): string { + if (!encoded) return 'Unindexed'; + const parts = encoded.split('-').filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : encoded; +} diff --git a/frontend/src/lib/utils/project-url.ts b/frontend/src/lib/utils/project-url.ts new file mode 100644 index 00000000..7e1b4439 --- /dev/null +++ b/frontend/src/lib/utils/project-url.ts @@ -0,0 +1,50 @@ +/** + * Canonical URL builder for project routes. + * + * A project has two identifiers — `slug` (pretty, user-facing) and + * `encoded_name` (the canonical filesystem-derived form used as DB PK). + * URLs prefer the slug for legibility but fall back to encoded_name + * when slug is missing (e.g. on session objects that predate the slug + * column or live sessions that haven't been indexed yet). + * + * Use this helper instead of inlining the `slug || encoded_name` + * fallback at call sites. The route param `[project_id]` accepts + * either form and the API normalizes both via + * `resolve_project_identifier`, so the choice is purely a URL-cosmetics + * decision — but it should be consistent across the app. + */ + +export interface ProjectIdentifierSource { + slug?: string | null; + encoded_name?: string | null; +} + +/** + * Build a `/projects/{id}` URL, optionally with a trailing suffix. + * + * @example + * projectHref({ slug: 'claude-karma-1044' }) // → '/projects/claude-karma-1044' + * projectHref({ encoded_name: '-Users-me-proj' }) // → '/projects/-Users-me-proj' + * projectHref({ slug: 's', encoded_name: 'e' }, '/abc') // → '/projects/s/abc' + */ +export function projectHref(p: ProjectIdentifierSource, suffix = ''): string { + const id = p.slug || p.encoded_name || ''; + return `/projects/${id}${suffix}`; +} + +/** + * Convenience wrapper for sessions — sessions carry both + * `project_slug` and `project_encoded_name`. Returns just the project + * identifier, no session segment. + */ +export interface SessionProjectFields { + project_slug?: string | null; + project_encoded_name?: string | null; +} + +export function projectHrefFromSession(s: SessionProjectFields, suffix = ''): string { + return projectHref( + { slug: s.project_slug, encoded_name: s.project_encoded_name }, + suffix + ); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4dc24501..7fc90c2d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -53,7 +53,7 @@ // /agents/[name] - agent detail (2 segments under /agents/) if (path.startsWith('/agents/') && path.split('/').filter(Boolean).length === 2) return 'agent-detail'; - // /projects/[project_slug]/[session_slug]/agents/[agent_id] - agent session detail + // /projects/[project_id]/[session_slug]/agents/[agent_id] - agent session detail // Must have 4+ segments: projects/slug/session/agents/id if ( path.startsWith('/projects/') && @@ -62,11 +62,11 @@ ) { return 'agent-session'; } - // /projects/[project_slug]/[session_slug] - session detail (3+ segments) + // /projects/[project_id]/[session_slug] - session detail (3+ segments) if (path.startsWith('/projects/') && path.split('/').filter(Boolean).length >= 3) { return 'session-detail'; } - // /projects/[project_slug] - project detail (2 segments) + // /projects/[project_id] - project detail (2 segments) if (path.startsWith('/projects/') && path.split('/').filter(Boolean).length === 2) { return 'project-detail'; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 5003568d..8f53b91e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -7,14 +7,14 @@ LineChart, Bot, Wrench, - History, Settings, FileText, MessageSquare, Puzzle, Cable, Webhook, - Terminal + Terminal, + Ticket } from 'lucide-svelte'; </script> @@ -28,11 +28,11 @@ <NavigationCard title="Plans" href="/plans" icon={FileText} color="yellow" /> <NavigationCard title="Skills" href="/skills" icon={Wrench} color="orange" /> <NavigationCard title="Agents" href="/agents" icon={Bot} color="purple" /> - <NavigationCard title="Tools" href="/tools" icon={Cable} color="teal" /> - <NavigationCard title="Hooks" href="/hooks" icon={Webhook} color="amber" /> + <NavigationCard title="Tools" href="/tools" icon={Cable} color="indigo" /> + <NavigationCard title="Hooks" href="/hooks" icon={Webhook} color="cyan" /> <NavigationCard title="Commands" href="/commands" icon={Terminal} color="red" /> <NavigationCard title="Plugins" href="/plugins" icon={Puzzle} color="violet" /> - <NavigationCard title="Archived" href="/archived" icon={History} color="gray" /> + <NavigationCard title="Tickets" href="/tickets" icon={Ticket} color="amber" /> <NavigationCard title="Settings" href="/settings" icon={Settings} color="indigo" /> </div> diff --git a/frontend/src/routes/commands/+page.svelte b/frontend/src/routes/commands/+page.svelte index c201f369..393bd8c6 100644 --- a/frontend/src/routes/commands/+page.svelte +++ b/frontend/src/routes/commands/+page.svelte @@ -432,7 +432,7 @@ <PageHeader title="Commands" icon={Terminal} - iconColor="--nav-blue" + iconColor="--nav-red" breadcrumbs={[{ label: 'Dashboard', href: '/' }, { label: 'Commands' }]} subtitle="Track command usage analytics across all sessions" /> diff --git a/frontend/src/routes/hooks/+page.svelte b/frontend/src/routes/hooks/+page.svelte index 6b20ec6b..1ebdc3e6 100644 --- a/frontend/src/routes/hooks/+page.svelte +++ b/frontend/src/routes/hooks/+page.svelte @@ -141,7 +141,7 @@ <PageHeader title="Hooks" icon={Webhook} - iconColor="--nav-amber" + iconColor="--nav-cyan" breadcrumbs={[{ label: 'Dashboard', href: '/' }, { label: 'Hooks' }]} subtitle="Hook scripts intercepting your Claude Code sessions" /> diff --git a/frontend/src/routes/plans/[slug]/+page.svelte b/frontend/src/routes/plans/[slug]/+page.svelte index a4fdcfda..d1984e3c 100644 --- a/frontend/src/routes/plans/[slug]/+page.svelte +++ b/frontend/src/routes/plans/[slug]/+page.svelte @@ -4,6 +4,7 @@ import PageHeader from '$lib/components/layout/PageHeader.svelte'; import Card from '$lib/components/ui/Card.svelte'; import { PlanViewer } from '$lib/components/plan'; + import { projectHrefFromSession } from '$lib/utils/project-url'; import { navigating } from '$app/stores'; import { PlanDetailSkeleton } from '$lib/components/skeleton'; @@ -65,8 +66,10 @@ {#snippet headerRight()} {#if data.sessionContext} <a - href="/projects/{data.sessionContext.project_encoded_name}/{data.sessionContext - .session_slug}" + href={projectHrefFromSession( + data.sessionContext, + `/${data.sessionContext.session_slug}` + )} class="inline-flex items-center gap-2 text-sm text-[var(--accent)] hover:underline" > <ExternalLink size={14} /> @@ -85,8 +88,10 @@ </div> <div class="flex flex-wrap gap-2"> <a - href="/projects/{data.sessionContext.project_encoded_name}/{data.sessionContext - .session_slug}" + href={projectHrefFromSession( + data.sessionContext, + `/${data.sessionContext.session_slug}` + )} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-subtle)] text-[var(--accent)] hover:bg-[var(--accent)]/20 transition-colors" > @@ -103,7 +108,7 @@ {:then relatedSessions} {#each relatedSessions as session} <a - href="/projects/{session.project_encoded_name}/{session.session_slug}" + href={projectHrefFromSession(session, `/${session.session_slug}`)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--bg-subtle)] text-[var(--text-secondary)] hover:bg-[var(--bg-muted)] transition-colors" > @@ -134,7 +139,7 @@ <div class="flex flex-wrap gap-2"> {#each relatedSessions as session} <a - href="/projects/{session.project_encoded_name}/{session.session_slug}" + href={projectHrefFromSession(session, `/${session.session_slug}`)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--bg-subtle)] text-[var(--text-secondary)] hover:bg-[var(--bg-muted)] transition-colors" > diff --git a/frontend/src/routes/projects/[project_slug]/+page.server.ts b/frontend/src/routes/projects/[project_id]/+page.server.ts similarity index 92% rename from frontend/src/routes/projects/[project_slug]/+page.server.ts rename to frontend/src/routes/projects/[project_id]/+page.server.ts index d3810168..896d4d98 100644 --- a/frontend/src/routes/projects/[project_slug]/+page.server.ts +++ b/frontend/src/routes/projects/[project_id]/+page.server.ts @@ -45,20 +45,20 @@ export async function load({ params, fetch, url }) { // Fetch project (required) and supplementary data (optional) in parallel // Analytics is excluded - will be fetched client-side on-demand for better initial load performance const [projectResult, branches, archived, liveSessions] = await Promise.all([ - safeFetch<Project>(fetch, `${API_BASE}/projects/${params.project_slug}?${projectParams}`), + safeFetch<Project>(fetch, `${API_BASE}/projects/${params.project_id}?${projectParams}`), fetchWithFallback<BranchesData>( fetch, - `${API_BASE}/projects/${params.project_slug}/branches`, + `${API_BASE}/projects/${params.project_id}/branches`, emptyBranches ), fetchWithFallback<ProjectArchivedResponse>( fetch, - `${API_BASE}/history/archived/${params.project_slug}`, + `${API_BASE}/history/archived/${params.project_id}`, emptyArchived ), fetchWithFallback<LiveSessionSummary[]>( fetch, - `${API_BASE}/live-sessions/project/${params.project_slug}`, + `${API_BASE}/live-sessions/project/${params.project_id}`, [] ) ]); diff --git a/frontend/src/routes/projects/[project_slug]/+page.svelte b/frontend/src/routes/projects/[project_id]/+page.svelte similarity index 99% rename from frontend/src/routes/projects/[project_slug]/+page.svelte rename to frontend/src/routes/projects/[project_id]/+page.svelte index 5c875727..3a7344b5 100644 --- a/frontend/src/routes/projects/[project_slug]/+page.svelte +++ b/frontend/src/routes/projects/[project_id]/+page.svelte @@ -50,6 +50,8 @@ import FiltersBottomSheet from '$lib/components/FiltersBottomSheet.svelte'; import ActiveFilterChips from '$lib/components/ActiveFilterChips.svelte'; import LiveSessionsSection from '$lib/components/LiveSessionsSection.svelte'; + import { ProjectTicketsTab } from '$lib/components/tickets'; + import { Ticket as TicketIcon } from 'lucide-svelte'; import type { Project, BranchesData, @@ -306,7 +308,7 @@ }); // Tab state - initialize from URL immediately (not deferred to onMount) - const validTabs = ['overview', 'analytics', 'agents', 'skills', 'tools', 'memory', 'archived']; + const validTabs = ['overview', 'analytics', 'agents', 'skills', 'tools', 'memory', 'tickets', 'archived']; const initialTab = $page.url.searchParams.get('tab'); let activeTab = $state(initialTab && validTabs.includes(initialTab) ? initialTab : 'overview'); let tabsReady = $state(false); @@ -402,7 +404,7 @@ const slug = window.location.pathname.split('/')[2]; const scrollKey = `project_scroll_${slug}`; const lastKey = `${LAST_OPENED_KEY_PREFIX}${slug}`; - if (to?.route.id === '/projects/[project_slug]/[session_slug]') { + if (to?.route.id === '/projects/[project_id]/[session_slug]') { sessionStorage.setItem(scrollKey, String(window.scrollY)); const id = to.url.pathname.split('/').pop() ?? ''; if (id) sessionStorage.setItem(lastKey, id); @@ -1074,6 +1076,7 @@ <TabsTrigger value="skills" icon={Wrench}>Project Skills</TabsTrigger> <TabsTrigger value="tools" icon={Cable}>Project Tools</TabsTrigger> <TabsTrigger value="memory" icon={Brain}>Project Memory</TabsTrigger> + <TabsTrigger value="tickets" icon={TicketIcon}>Tickets</TabsTrigger> <TabsTrigger value="analytics" icon={BarChart3}>Analytics</TabsTrigger> {#if archived.total_sessions > 0} <TabsTrigger value="archived" icon={Archive}> @@ -1725,6 +1728,13 @@ <MemoryViewer projectEncodedName={project.encoded_name} /> </Tabs.Content> + <!-- Tickets Tab (Q9a A — full Tickets tab) --> + <Tabs.Content value="tickets" class="animate-fade-in"> + {#if project?.encoded_name} + <ProjectTicketsTab projectEncodedName={project.encoded_name} /> + {/if} + </Tabs.Content> + <!-- Archived Tab --> {#if archived.total_sessions > 0} <Tabs.Content value="archived" class="animate-fade-in"> diff --git a/frontend/src/routes/projects/[project_slug]/[session_slug]/+page.server.ts b/frontend/src/routes/projects/[project_id]/[session_slug]/+page.server.ts similarity index 80% rename from frontend/src/routes/projects/[project_slug]/[session_slug]/+page.server.ts rename to frontend/src/routes/projects/[project_id]/[session_slug]/+page.server.ts index 4f121178..f065dadf 100644 --- a/frontend/src/routes/projects/[project_slug]/[session_slug]/+page.server.ts +++ b/frontend/src/routes/projects/[project_id]/[session_slug]/+page.server.ts @@ -1,14 +1,18 @@ -import type { LiveSessionSummary, PlanDetail } from '$lib/api-types'; +import type { LiveSessionSummary, PlanDetail, SessionTicketRow } from '$lib/api-types'; import { API_BASE } from '$lib/config'; import { safeFetch, fetchWithFallback } from '$lib/utils/api-fetch'; /** - * Session lookup result from the fast lookup endpoint. + * Session lookup result from the fast lookup endpoint + * (see api/routers/projects.py:lookup_session). The `project_encoded_name` + * field is the API's canonical identifier — extract it from the load and + * pass it as `encodedName` to ConversationView; do NOT thread the URL + * param (which can be a slug) through as encodedName. */ interface SessionLookupResult { uuid: string; slug: string | null; - project_project_slug: string; + project_encoded_name: string; project_path: string; message_count: number; start_time: string | null; @@ -18,13 +22,13 @@ interface SessionLookupResult { } export async function load({ params, fetch }) { - const { project_slug, session_slug } = params; + const { project_id, session_slug } = params; // Step 1: Use fast lookup endpoint to resolve slug/UUID to session UUID // This is ~100x faster than loading all sessions const lookupResult = await safeFetch<SessionLookupResult>( fetch, - `${API_BASE}/projects/${project_slug}/sessions/lookup?identifier=${encodeURIComponent(session_slug)}` + `${API_BASE}/projects/${project_id}/sessions/lookup?identifier=${encodeURIComponent(session_slug)}` ); // If lookup fails, check for "starting" live session or return error @@ -46,8 +50,9 @@ export async function load({ params, fetch }) { plan: null, liveSession: matchingLiveSession, isStarting: true, - project_slug, + project_id, session_slug, + tickets: [] as SessionTicketRow[], error: null }; } @@ -61,8 +66,9 @@ export async function load({ params, fetch }) { plan: null, liveSession: null, isStarting: false, - project_slug, + project_id, session_slug, + tickets: [] as SessionTicketRow[], error: isNotFound ? `Session not found: ${session_slug}` : `Failed to lookup session: ${lookupResult.message}` @@ -72,6 +78,9 @@ export async function load({ params, fetch }) { const sessionLookup = lookupResult.data; const sessionUuid = sessionLookup.uuid; const projectPath = sessionLookup.project_path; + // Canonical encoded_name from the API — pass to children that need + // the system identifier (e.g., ConversationView's encodedName prop). + const projectEncodedName = sessionLookup.project_encoded_name; // Step 4: Fetch detailed session data using UUID // Use safeFetch for the main session, fetchWithFallback for supplementary data @@ -83,7 +92,8 @@ export async function load({ params, fetch }) { subagentsData, toolsData, tasksData, - planResult + planResult, + ticketsData ] = await Promise.all([ safeFetch<Record<string, unknown>>(fetch, `${API_BASE}/sessions/${sessionUuid}`), fetchWithFallback(fetch, `${API_BASE}/sessions/${sessionUuid}/timeline`, []), @@ -91,7 +101,12 @@ export async function load({ params, fetch }) { fetchWithFallback(fetch, `${API_BASE}/sessions/${sessionUuid}/subagents`, []), fetchWithFallback(fetch, `${API_BASE}/sessions/${sessionUuid}/tools`, []), fetchWithFallback(fetch, `${API_BASE}/sessions/${sessionUuid}/tasks`, []), - safeFetch<PlanDetail>(fetch, `${API_BASE}/sessions/${sessionUuid}/plan`) + safeFetch<PlanDetail>(fetch, `${API_BASE}/sessions/${sessionUuid}/plan`), + fetchWithFallback<SessionTicketRow[]>( + fetch, + `${API_BASE}/sessions/${sessionUuid}/tickets`, + [] + ) ]); if (!sessionResult.ok) { @@ -101,8 +116,9 @@ export async function load({ params, fetch }) { plan: null, liveSession: null, isStarting: false, - project_slug, + project_id, session_slug, + tickets: [] as SessionTicketRow[], error: `Failed to load session: ${sessionResult.message}` }; } @@ -185,8 +201,10 @@ export async function load({ params, fetch }) { plan, liveSession: null, isStarting: false, - project_slug, + project_id, + project_encoded_name: projectEncodedName, session_slug, + tickets: (ticketsData ?? []) as SessionTicketRow[], error: null }; } diff --git a/frontend/src/routes/projects/[project_slug]/[session_slug]/+page.svelte b/frontend/src/routes/projects/[project_id]/[session_slug]/+page.svelte similarity index 86% rename from frontend/src/routes/projects/[project_slug]/[session_slug]/+page.svelte rename to frontend/src/routes/projects/[project_id]/[session_slug]/+page.svelte index 9ba04903..35b6ca0f 100644 --- a/frontend/src/routes/projects/[project_slug]/[session_slug]/+page.svelte +++ b/frontend/src/routes/projects/[project_id]/[session_slug]/+page.svelte @@ -3,6 +3,7 @@ import type { SessionDetail, LiveSessionSummary, + SessionTicketRow, ToolUsage, Task, PlanDetail @@ -17,10 +18,11 @@ let session = $derived(data.session as SessionDetail | null); let plan = $derived(data.plan as PlanDetail | null); let error = $derived(data.error as string | null); + let tickets = $derived((data.tickets ?? []) as SessionTicketRow[]); let isLoading = $derived( !!$navigating && - $navigating.to?.route.id === '/projects/[project_slug]/[session_slug]' + $navigating.to?.route.id === '/projects/[project_id]/[session_slug]' ); </script> @@ -39,7 +41,7 @@ <h1 class="text-xl font-semibold text-[var(--text-primary)]">Failed to Load Session</h1> <p class="text-[var(--text-secondary)]">{error}</p> <a - href="/projects/{data.project_slug}" + href="/projects/{data.project_id}" class="inline-flex items-center gap-2 mt-4 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity" > <ArrowLeft size={16} /> @@ -50,8 +52,9 @@ {:else} <ConversationView entity={session} - encodedName={data.project_slug} + encodedName={data.project_encoded_name ?? data.project_id} sessionSlug={data.session_slug} + sessionUuid={session?.uuid} projectPath={session?.project_path} liveSession={data.liveSession as LiveSessionSummary | null} isStarting={data.isStarting} @@ -60,5 +63,6 @@ tools={session?.tools_used as unknown as ToolUsage[] | undefined} tasks={session?.tasks as Task[] | undefined} {plan} + {tickets} /> {/if} diff --git a/frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.server.ts b/frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.server.ts similarity index 92% rename from frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.server.ts rename to frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.server.ts index c69b5afe..653a80bb 100644 --- a/frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.server.ts +++ b/frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.server.ts @@ -22,13 +22,13 @@ interface SessionLookupResult { } export async function load({ params, fetch }) { - const { project_slug, session_slug, agent_id } = params; + const { project_id, session_slug, agent_id } = params; // Step 1: Use fast lookup endpoint to resolve slug/UUID to session UUID - // This is ~350x faster than loading all sessions via /projects/{project_slug} + // This is ~350x faster than loading all sessions via /projects/{project_id} const lookupResult = await safeFetch<SessionLookupResult>( fetch, - `${API_BASE}/projects/${project_slug}/sessions/lookup?identifier=${encodeURIComponent(session_slug)}` + `${API_BASE}/projects/${project_id}/sessions/lookup?identifier=${encodeURIComponent(session_slug)}` ); if (!lookupResult.ok) { @@ -38,7 +38,7 @@ export async function load({ params, fetch }) { fileActivity: [], tools: [], tasks: [], - project_slug, + project_id, session_slug, session_uuid: null, parent_session_slug: session_slug, @@ -76,7 +76,7 @@ export async function load({ params, fetch }) { tools: [], tasks: [], liveSession: null, - project_slug, + project_id, session_slug, session_uuid: sessionUuid, parent_session_slug: sessionLookup.slug || sessionUuid.slice(0, 8), @@ -98,7 +98,8 @@ export async function load({ params, fetch }) { tools: tools_used, tasks: tasksData, liveSession: liveSessionResult.ok ? liveSessionResult.data : null, - project_slug, + project_id, + project_encoded_name: encodedName, session_slug, session_uuid: sessionUuid, parent_session_slug: sessionLookup.slug || sessionUuid.slice(0, 8), diff --git a/frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.svelte b/frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.svelte similarity index 91% rename from frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.svelte rename to frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.svelte index 2be77ac4..f382375c 100644 --- a/frontend/src/routes/projects/[project_slug]/[session_slug]/agents/[agent_id]/+page.svelte +++ b/frontend/src/routes/projects/[project_id]/[session_slug]/agents/[agent_id]/+page.svelte @@ -19,7 +19,7 @@ let isLoading = $derived( !!$navigating && $navigating.to?.route.id === - '/projects/[project_slug]/[session_slug]/agents/[agent_id]' + '/projects/[project_id]/[session_slug]/agents/[agent_id]' ); </script> @@ -38,7 +38,7 @@ <h1 class="text-xl font-semibold text-[var(--text-primary)]">Failed to Load Agent</h1> <p class="text-[var(--text-secondary)]">{error}</p> <a - href="/projects/{data.project_slug}/{data.session_slug}" + href="/projects/{data.project_id}/{data.session_slug}" class="inline-flex items-center gap-2 mt-4 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity" > <ArrowLeft size={16} /> @@ -49,7 +49,7 @@ {:else} <ConversationView entity={data.agent as SubagentSessionDetail} - encodedName={data.project_slug} + encodedName={data.project_encoded_name ?? data.project_id} sessionSlug={data.session_slug} parentSessionSlug={data.parent_session_slug ?? undefined} projectPath={data.project_path ?? undefined} diff --git a/frontend/src/routes/sessions/+page.svelte b/frontend/src/routes/sessions/+page.svelte index bc9483b1..d0f2d917 100644 --- a/frontend/src/routes/sessions/+page.svelte +++ b/frontend/src/routes/sessions/+page.svelte @@ -542,7 +542,7 @@ beforeNavigate(({ to }) => { if (!browser) return; - if (to?.route.id === '/projects/[project_slug]/[session_slug]') { + if (to?.route.id === '/projects/[project_id]/[session_slug]') { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); sessionStorage.setItem(PAGE_KEY, String(data.filters.page)); const id = to.url.pathname.split('/').pop() ?? ''; diff --git a/frontend/src/routes/tickets/+page.server.ts b/frontend/src/routes/tickets/+page.server.ts new file mode 100644 index 00000000..2272786d --- /dev/null +++ b/frontend/src/routes/tickets/+page.server.ts @@ -0,0 +1,30 @@ +import type { TicketListItem } from '$lib/api-types'; +import { API_BASE } from '$lib/config'; +import { fetchWithFallback } from '$lib/utils/api-fetch'; + +export async function load({ url, fetch }) { + const provider = url.searchParams.get('provider') ?? ''; + const q = url.searchParams.get('q') ?? ''; + const project = url.searchParams.get('project') ?? ''; + // `kind` is GitHub-only (issue|pull_request). Filtering happens + // client-side via githubKindFromUrl(), so we don't send it to the API + // — but we surface it as URL state so links remain shareable. + const kind = url.searchParams.get('kind') ?? ''; + + const qs = new URLSearchParams(); + if (provider) qs.set('provider', provider); + if (q) qs.set('q', q); + if (project) qs.set('project', project); + const queryString = qs.toString(); + + const tickets = await fetchWithFallback<TicketListItem[]>( + fetch, + `${API_BASE}/tickets${queryString ? `?${queryString}` : ''}`, + [] + ); + + return { + tickets: tickets ?? [], + filters: { provider, q, project, kind } + }; +} diff --git a/frontend/src/routes/tickets/+page.svelte b/frontend/src/routes/tickets/+page.svelte new file mode 100644 index 00000000..bab5b7fc --- /dev/null +++ b/frontend/src/routes/tickets/+page.svelte @@ -0,0 +1,311 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + import { page } from '$app/stores'; + import type { TicketListItem, TicketProvider } from '$lib/api-types'; + import { + PROVIDER_META, + normalizeStatus, + statusColorVar, + formatRelative, + githubKindFromUrl, + type GithubKind + } from '$lib/ticket-helpers'; + import { + Search, + ArrowRight, + ExternalLink, + Ticket as TicketIcon, + CornerDownRight + } from 'lucide-svelte'; + import PageHeader from '$lib/components/layout/PageHeader.svelte'; + import ProviderChip from '$lib/components/tickets/ProviderChip.svelte'; + import TicketEmptyState from '$lib/components/tickets/TicketEmptyState.svelte'; + + let { data } = $props(); + + let q = $state(data.filters.q); + let provider = $state<TicketProvider | ''>((data.filters.provider as TicketProvider | '') ?? ''); + // GitHub kind sub-filter. Only meaningful when provider === 'github'. + // URL param `kind` ∈ '' | 'issue' | 'pull_request'. Cleared automatically + // when provider changes away from github (see setProvider below). + let kind = $state<'' | GithubKind>((data.filters.kind as '' | GithubKind) ?? ''); + + function navigate(opts: { q?: string; provider?: string; project?: string; kind?: string }) { + const params = new URLSearchParams($page.url.searchParams); + for (const [k, v] of Object.entries(opts)) { + if (v) params.set(k, v); + else params.delete(k); + } + goto(`/tickets?${params.toString()}`); + } + + function setProvider(next: '' | TicketProvider) { + provider = next; + // kind only applies under GitHub; drop it when switching away. + const nextKind = next === 'github' ? kind : ''; + if (next !== 'github') kind = ''; + navigate({ provider: next, kind: nextKind }); + } + + function setKind(next: '' | GithubKind) { + kind = next; + navigate({ kind: next }); + } + + function submitSearch(e: Event) { + e.preventDefault(); + navigate({ q }); + } + + function clearProject() { + navigate({ project: '' }); + } + + // Counts per provider — used in the segmented filter + let counts = $derived({ + all: data.tickets.length, + linear: data.tickets.filter((t: TicketListItem) => t.provider === 'linear').length, + jira: data.tickets.filter((t: TicketListItem) => t.provider === 'jira').length, + github: data.tickets.filter((t: TicketListItem) => t.provider === 'github').length + }); + + // Counts for the GH kind sub-filter, computed from the loaded list. + // Only used when provider === 'github'. + let githubKindCounts = $derived({ + all: data.tickets.filter((t: TicketListItem) => t.provider === 'github').length, + issue: data.tickets.filter( + (t: TicketListItem) => t.provider === 'github' && githubKindFromUrl(t.url) === 'issue' + ).length, + pull_request: data.tickets.filter( + (t: TicketListItem) => + t.provider === 'github' && githubKindFromUrl(t.url) === 'pull_request' + ).length + }); + + // data.tickets is already server-filtered by provider/q/project. The + // kind sub-filter is client-side because kind is derivable from URL — + // no need to ask the backend. + let visibleTickets = $derived( + kind && provider === 'github' + ? data.tickets.filter( + (t: TicketListItem) => + t.provider === 'github' && githubKindFromUrl(t.url) === kind + ) + : data.tickets + ); + + const PROVIDERS: { id: '' | TicketProvider; label: string }[] = [ + { id: '', label: 'All' }, + { id: 'linear', label: 'Linear' }, + { id: 'jira', label: 'Jira' }, + { id: 'github', label: 'GitHub' } + ]; + + const GH_KINDS: { id: '' | GithubKind; label: string }[] = [ + { id: '', label: 'All' }, + { id: 'issue', label: 'Issues' }, + { id: 'pull_request', label: 'PRs' } + ]; + + const hasFilters = $derived( + !!(data.filters.q || data.filters.provider || data.filters.project || data.filters.kind) + ); +</script> + +<svelte:head> + <title>Tickets · Claude Karma + + +
+ + + {#if data.tickets.length === 0 && !hasFilters} +
+ +
+ {:else} + +
+
+
+ {#each PROVIDERS as opt (opt.id)} + {@const active = provider === opt.id} + {@const meta = opt.id ? PROVIDER_META[opt.id] : null} + + {/each} +
+
+
+ + +
+
+
+ + + {#if provider === 'github'} +
+
+ {/if} +
+ + {#if data.filters.project} +
+ Filtered to project: + {data.filters.project} + +
+ {/if} + + {#if visibleTickets.length === 0} +
+

No tickets match your filters.

+
+ {:else} + + + {/if} + {/if} +
diff --git a/frontend/src/routes/tickets/[provider]/[external_key]/+page.server.ts b/frontend/src/routes/tickets/[provider]/[external_key]/+page.server.ts new file mode 100644 index 00000000..a38934e7 --- /dev/null +++ b/frontend/src/routes/tickets/[provider]/[external_key]/+page.server.ts @@ -0,0 +1,42 @@ +import type { Ticket, TicketDetailSessionRow } from '$lib/api-types'; +import { API_BASE } from '$lib/config'; +import { safeFetch, fetchWithFallback } from '$lib/utils/api-fetch'; + +type TicketSessionRow = TicketDetailSessionRow; + +export async function load({ params, fetch }) { + const { provider, external_key } = params; + // external_key may contain '/' for GitHub-style refs; SvelteKit's + // dynamic param won't capture beyond the next segment. The route + // dir nests as /tickets/[provider]/[external_key] so this works for + // Linear/Jira; for GitHub the URL must be percent-encoded ('%2F' or + // alternatively the index uses encodeURIComponent on the link). + const keyEncoded = encodeURIComponent(external_key); + + const [ticketResult, sessions] = await Promise.all([ + safeFetch(fetch, `${API_BASE}/tickets/${provider}/${keyEncoded}`), + fetchWithFallback( + fetch, + `${API_BASE}/tickets/${provider}/${keyEncoded}/sessions`, + [] + ) + ]); + + if (!ticketResult.ok) { + return { + ticket: null, + sessions: [] as TicketSessionRow[], + provider, + external_key, + error: ticketResult.message ?? 'Ticket not found' + }; + } + + return { + ticket: ticketResult.data, + sessions: sessions ?? [], + provider, + external_key, + error: null + }; +} diff --git a/frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte b/frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte new file mode 100644 index 00000000..010eb308 --- /dev/null +++ b/frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte @@ -0,0 +1,347 @@ + + + + {data.ticket ? `${data.ticket.external_key} · Tickets` : 'Ticket not found'} + + +
+ +
+ + + Tickets + + + + {data.provider}/{data.external_key} + +
+ + {#if data.error || !data.ticket || !meta || !norm} +
+ +

Ticket not found

+

+ No karma record for {data.provider}/{data.external_key}. +

+
+ {:else} + +
+ + +
+
+ {#if data.ticket.title} +

+ {data.ticket.title} +

+ {:else} +

+ title not yet fetched +

+ {/if} +

+ {#if data.ticket.status} + + + {data.ticket.status} + + · + {/if} + + {data.sessions.length} + {data.sessions.length === 1 ? 'session' : 'sessions'} + {#if activeCount > 0} + ({activeCount} active) + {/if} + + · + + {projectCount || (data.sessions.length ? 1 : 0)} + {(projectCount || (data.sessions.length ? 1 : 0)) === 1 ? 'project' : 'projects'} + + · + first seen {formatRelative(data.ticket.first_seen_at)} + {#if data.ticket.metadata_updated_at && !isSyncedNearFirstSeen(data.ticket.first_seen_at, data.ticket.metadata_updated_at)} + · + synced {formatRelative(data.ticket.metadata_updated_at)} + {/if} +

+
+
+
+ + +
+

+ Sessions +

+ · sorted by most recently linked +
+ + {#if showTabs} +
+ + {#each buckets as b (b.key)} + + {/each} +
+ {/if} + + {#if data.sessions.length === 0} +

+ No sessions linked to this ticket. Open a session and paste + {data.ticket.external_key} to link one. +

+ {:else} +
+ {#each visibleSessions as s, i (s.link_id)} + {@const badge = liveBadge(s.live?.status)} + {@const isLive = !!s.live && ['LIVE', 'WAITING', 'STARTING'].includes(s.live.status)} + {@const isActive = isLive || (!!s.start_time && !s.end_time)} + {@const isOrphan = !s.sessions_slug && !s.live} + {@const slugLabel = s.sessions_slug ?? s.session_slug ?? null} + {@const navIdentifier = slugLabel ?? s.session_uuid.slice(0, 8)} + {@const href = s.project_encoded_name + ? `/projects/${s.project_encoded_name}/${navIdentifier}` + : null} + + + +
+
+ {#if isOrphan} + + {s.session_slug ?? s.session_uuid.slice(0, 8)} · no data + + {:else if slugLabel} + {slugLabel} + {:else} + + {s.session_uuid.slice(0, 8)} + + {/if} + {#if badge} + + {badge.label} + + {:else if isActive && !s.live} + ACTIVE + {/if} + + {sourceLabel(s.link_source)} + + {#if activeKey === '__all__' && s.project_encoded_name} + + + {projectDisplayName(s.project_encoded_name)} + + {/if} +
+ {#if s.initial_prompt} +
+ {s.initial_prompt} +
+ {/if} +
+ + + {formatRelative(s.start_time)} + + + linked {formatRelative(s.linked_at)} + +
+ {/each} +
+ {/if} + {/if} +
diff --git a/frontend/src/routes/tools/+page.svelte b/frontend/src/routes/tools/+page.svelte index d85d1074..f90cc9f8 100644 --- a/frontend/src/routes/tools/+page.svelte +++ b/frontend/src/routes/tools/+page.svelte @@ -274,7 +274,7 @@ diff --git a/frontend/src/tests/ticket-helpers.test.ts b/frontend/src/tests/ticket-helpers.test.ts new file mode 100644 index 00000000..6f6f7f08 --- /dev/null +++ b/frontend/src/tests/ticket-helpers.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { githubKindFromUrl } from '$lib/ticket-helpers'; + +// ============================================================ +// githubKindFromUrl +// ============================================================ +describe('githubKindFromUrl', () => { + it('returns "pull_request" for /pull/N URLs', () => { + expect( + githubKindFromUrl('https://github.com/octocat/repo/pull/42') + ).toBe('pull_request'); + }); + + it('returns "issue" for /issues/N URLs', () => { + expect( + githubKindFromUrl('https://github.com/octocat/repo/issues/42') + ).toBe('issue'); + }); + + it('returns "pull_request" even with query and fragment trailing', () => { + expect( + githubKindFromUrl('https://github.com/octocat/repo/pull/9?diff=1#x') + ).toBe('pull_request'); + }); + + it('defaults to "issue" for null / empty / unrecognized URLs', () => { + // We pick "issue" as the safe default — GitHub redirects /issues/N + // to /pull/N when N is actually a PR, so the link still resolves. + expect(githubKindFromUrl(null)).toBe('issue'); + expect(githubKindFromUrl(undefined)).toBe('issue'); + expect(githubKindFromUrl('')).toBe('issue'); + expect(githubKindFromUrl('https://linear.app/team/issue/ABC-1')).toBe( + 'issue' + ); + }); + + it('does not false-match a literal "/pull/" elsewhere in the path', () => { + // Guards against /someuser/pull/request-repo/issues/1 nonsense. + // Path regex requires /pull/ after owner/repo segments. + expect( + githubKindFromUrl('https://github.com/owner/repo/issues/1?file=/pull/x') + ).toBe('issue'); + }); + + it('does not false-match /pull/ hidden in the query string', () => { + // Regression: an earlier implementation tested against the raw URL + // and would have returned 'pull_request' here. The fix narrows the + // check to URL.pathname so query strings can't lie about kind. + expect( + githubKindFromUrl('https://github.com/owner/repo/issues/1?file=/pull/9') + ).toBe('issue'); + }); + + it('returns "issue" for malformed URLs instead of throwing', () => { + // new URL() throws on garbage; the helper must catch and fall back + // to the safe default rather than crash the render path. + expect(githubKindFromUrl('not a url')).toBe('issue'); + expect(githubKindFromUrl('://garbage')).toBe('issue'); + }); +}); diff --git a/hooks/tests/__init__.py b/hooks/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/tests/test_ticket_branch_detector.py b/hooks/tests/test_ticket_branch_detector.py new file mode 100644 index 00000000..03ef97f5 --- /dev/null +++ b/hooks/tests/test_ticket_branch_detector.py @@ -0,0 +1,299 @@ +""" +Tests for hooks/ticket_branch_detector.py. + +The hook is invoked by Claude Code via stdin → SessionStart JSON payload. +We exercise it via subprocess so we cover the real entry point, with +HOME pointed at a tmp dir so config and live-sessions don't pollute the +user's machine. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + +import pytest + +HOOK = Path(__file__).resolve().parent.parent / "ticket_branch_detector.py" + + +@pytest.fixture +def home(tmp_path, monkeypatch) -> Path: + """Re-home the user so ~/.claude_karma/* lives under tmp.""" + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def repo(tmp_path) -> Path: + """A real git repo on a branch named feat/LINEAR-123-fix-login.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo_dir, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo_dir, check=True + ) + subprocess.run(["git", "config", "user.name", "test"], cwd=repo_dir, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo_dir, check=True + ) + subprocess.run( + ["git", "checkout", "-q", "-b", "feat/LINEAR-123-fix-login"], + cwd=repo_dir, + check=True, + ) + return repo_dir + + +def _run_hook( + payload: dict, + *, + home: Path, + karma_api: Optional[str] = None, + extra_env: Optional[dict] = None, +) -> subprocess.CompletedProcess: + env = dict(os.environ) + env["HOME"] = str(home) + if karma_api is not None: + env["CLAUDE_KARMA_API"] = karma_api + if extra_env: + env.update(extra_env) + return subprocess.run( + [sys.executable, str(HOOK)], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + timeout=15, + ) + + +def _write_config(home: Path, cfg: dict) -> None: + karma = home / ".claude_karma" + karma.mkdir(parents=True, exist_ok=True) + (karma / "config.json").write_text(json.dumps(cfg)) + + +def test_silent_exit_when_no_config(home, repo): + """No config file → opt-in default False → no-op, exit 0.""" + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", # unroutable port + ) + assert result.returncode == 0 + # No log entry because we exited before any I/O + log = home / ".claude_karma" / "logs" / "ticket_branch_detector.log" + assert not log.exists() + + +def test_silent_exit_when_disabled(home, repo): + """Explicit disabled flag → no-op.""" + _write_config(home, {"branch_detect_enabled": False, "ticket_branch_patterns": []}) + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + + +def test_silent_exit_when_no_branch_match(home, repo): + """Enabled but the configured regex doesn't match this branch name.""" + _write_config( + home, + { + "branch_detect_enabled": True, + "ticket_branch_patterns": [{"regex": r"^WONT-MATCH-\d+$", "provider": "linear"}], + }, + ) + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + + +def test_silent_exit_with_unreachable_karma(home, repo): + """When the API is unreachable, the hook logs but still exits 0.""" + _write_config( + home, + { + "branch_detect_enabled": True, + "ticket_branch_patterns": [ + {"regex": r"(?P[A-Z][A-Z0-9_]+-\d+)", "provider": "linear"} + ], + }, + ) + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", # unroutable + ) + assert result.returncode == 0 + # Log should record the POST failure. + log = home / ".claude_karma" / "logs" / "ticket_branch_detector.log" + assert log.exists() + assert "POST" in log.read_text() + + +def test_silent_exit_with_non_git_cwd(home, tmp_path): + """cwd that isn't a git repo → git lookup returns None → no-op.""" + _write_config( + home, + { + "branch_detect_enabled": True, + "ticket_branch_patterns": [ + {"regex": r"(?P[A-Z][A-Z0-9_]+-\d+)", "provider": "linear"} + ], + }, + ) + non_git = tmp_path / "no-git" + non_git.mkdir() + result = _run_hook( + {"session_id": "sess-1", "cwd": str(non_git)}, + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + log = home / ".claude_karma" / "logs" / "ticket_branch_detector.log" + assert not log.exists(), "should never have reached POST" + + +def test_silent_exit_with_missing_session_id(home, repo): + """Malformed payload (no session_id) — never raises.""" + _write_config( + home, + { + "branch_detect_enabled": True, + "ticket_branch_patterns": [ + {"regex": r"(?P[A-Z][A-Z0-9_]+-\d+)", "provider": "linear"} + ], + }, + ) + result = _run_hook( + {"cwd": str(repo)}, # no session_id + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + + +def test_silent_exit_with_corrupt_config(home, repo): + """Garbage config.json — falls back to defaults, hook exits 0.""" + karma = home / ".claude_karma" + karma.mkdir(parents=True, exist_ok=True) + (karma / "config.json").write_text("{not json") + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + + +def test_silent_exit_with_bad_regex(home, repo): + """Invalid regex in config is logged and skipped, doesn't crash.""" + _write_config( + home, + { + "branch_detect_enabled": True, + "ticket_branch_patterns": [ + {"regex": "(unclosed", "provider": "linear"}, + ], + }, + ) + result = _run_hook( + {"session_id": "sess-1", "cwd": str(repo)}, + home=home, + karma_api="http://127.0.0.1:1", + ) + assert result.returncode == 0 + + +# Pure-function tests on the hook module's helpers +# Module-import variant — exercises non-stdin paths directly. + + +def _import_hook_module(): + """Import the hook script as a module for direct unit testing.""" + import importlib.util + + spec = importlib.util.spec_from_file_location("ticket_branch_detector", HOOK) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def test_git_current_branch_returns_branch(repo): + mod = _import_hook_module() + assert mod.git_current_branch(str(repo)) == "feat/LINEAR-123-fix-login" + + +def test_git_current_branch_returns_none_for_non_git(tmp_path): + mod = _import_hook_module() + no_git = tmp_path / "x" + no_git.mkdir() + assert mod.git_current_branch(str(no_git)) is None + + +def test_match_pattern_extracts_named_group(): + mod = _import_hook_module() + patterns = [{"regex": r"(?P[A-Z][A-Z0-9_]+-\d+)", "provider": "linear"}] + assert mod.match_pattern("feat/LINEAR-123-fix-login", patterns) == ("linear", "LINEAR-123") + + +def test_match_pattern_returns_whole_match_when_no_named_group(): + mod = _import_hook_module() + patterns = [{"regex": r"[A-Z][A-Z0-9_]+-\d+", "provider": "jira"}] + assert mod.match_pattern("PROJ-45-stuff", patterns) == ("jira", "PROJ-45") + + +def test_match_pattern_first_match_wins(): + mod = _import_hook_module() + patterns = [ + {"regex": r"NOPE-\d+", "provider": "linear"}, + {"regex": r"(?P[A-Z][A-Z0-9_]+-\d+)", "provider": "jira"}, + ] + assert mod.match_pattern("FOO-1", patterns) == ("jira", "FOO-1") + + +def test_match_pattern_skips_unknown_provider(): + mod = _import_hook_module() + patterns = [{"regex": r"FOO-\d+", "provider": "bitbucket"}] + assert mod.match_pattern("FOO-1", patterns) is None + + +def test_lookup_slug_from_live_sessions_returns_best_match(home): + mod = _import_hook_module() + live = home / ".claude_karma" / "live-sessions" + live.mkdir(parents=True, exist_ok=True) + (live / "a.json").write_text( + json.dumps({"cwd": "/x/y", "slug": "old-slug", "last_updated": "2026-01-01T00:00:00Z"}) + ) + (live / "b.json").write_text( + json.dumps({"cwd": "/x/y", "slug": "new-slug", "last_updated": "2026-05-01T00:00:00Z"}) + ) + (live / "c.json").write_text( + json.dumps({"cwd": "/other", "slug": "wrong", "last_updated": "2026-12-01T00:00:00Z"}) + ) + assert mod.lookup_slug_from_live_sessions("/x/y") == "new-slug" + + +def test_lookup_slug_returns_none_when_no_match(home): + mod = _import_hook_module() + (home / ".claude_karma" / "live-sessions").mkdir(parents=True, exist_ok=True) + assert mod.lookup_slug_from_live_sessions("/no-match") is None + + +def test_load_config_uses_defaults_when_missing(home): + mod = _import_hook_module() + cfg = mod.load_config() + assert cfg["branch_detect_enabled"] is False + assert cfg["ticket_branch_patterns"] == [] diff --git a/hooks/ticket_branch_detector.py b/hooks/ticket_branch_detector.py new file mode 100755 index 00000000..f7b6529c --- /dev/null +++ b/hooks/ticket_branch_detector.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Branch-name → ticket-link detector for Claude Code Karma. + +Fires on SessionStart. Reads the session's cwd, asks git for the current +branch, matches against user-configured patterns (default: Linear/Jira +style `ABC-123`), and POSTs a link to the karma API. Silent on every +failure — this hook NEVER blocks SessionStart. + +Configuration (~/.claude_karma/config.json): + + { + "branch_detect_enabled": false, + "ticket_branch_patterns": [ + {"regex": "(?P[A-Z][A-Z0-9_]+-\\d+)", "provider": "linear"} + ] + } + +Opt-in: branch_detect_enabled defaults to False so users must explicitly +flip it on to avoid surprise links on personal-projects directories. + +See: docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +API_BASE = os.environ.get("CLAUDE_KARMA_API", "http://localhost:8000") +CONFIG_PATH = Path.home() / ".claude_karma" / "config.json" +LIVE_SESSIONS_DIR = Path.home() / ".claude_karma" / "live-sessions" +LOG_PATH = Path.home() / ".claude_karma" / "logs" / "ticket_branch_detector.log" +HTTP_TIMEOUT_SEC = 3 + +DEFAULT_CONFIG = { + "branch_detect_enabled": False, + "ticket_branch_patterns": [], +} + + +def _log(msg: str) -> None: + """Append a timestamped line to the hook log. Best-effort.""" + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + with LOG_PATH.open("a") as f: + f.write(f"{ts} {msg}\n") + except Exception: + pass + + +def load_config() -> dict: + """Load ~/.claude_karma/config.json with sensible defaults on any error.""" + if not CONFIG_PATH.exists(): + return DEFAULT_CONFIG + try: + loaded = json.loads(CONFIG_PATH.read_text()) + if not isinstance(loaded, dict): + return DEFAULT_CONFIG + return {**DEFAULT_CONFIG, **loaded} + except (OSError, json.JSONDecodeError) as e: + _log(f"config load failed: {e!r}") + return DEFAULT_CONFIG + + +def git_current_branch(cwd: str) -> Optional[str]: + """Return the current git branch, or None if not in a git repo / detached HEAD.""" + if not cwd: + return None + try: + result = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], + cwd=cwd, + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None + + if result.returncode != 0: + return None + branch = result.stdout.strip() + return branch or None + + +def lookup_slug_from_live_sessions(cwd: str) -> Optional[str]: + """Best-effort slug lookup via the live-sessions tracker's state files. + + Picks the live-sessions entry whose `cwd` matches and whose + `last_updated` is most recent. Returns None if nothing matches — + the link will then be deduped by session_uuid only, which is fine + for first-of-its-kind sessions. + """ + if not cwd or not LIVE_SESSIONS_DIR.exists(): + return None + + best_slug: Optional[str] = None + best_ts = "" + + try: + for path in LIVE_SESSIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + continue + if data.get("cwd") != cwd: + continue + ts = data.get("last_updated") or data.get("started_at") or "" + if ts >= best_ts: + best_ts = ts + best_slug = data.get("slug") + except OSError: + return None + + return best_slug + + +def match_pattern(branch: str, patterns: list) -> Optional[tuple[str, str]]: + """Return (provider, ref) for the first matching pattern, else None.""" + for entry in patterns: + if not isinstance(entry, dict): + continue + regex = entry.get("regex") + provider = entry.get("provider") + if not regex or provider not in ("linear", "jira", "github"): + continue + try: + m = re.search(regex, branch) + except re.error as e: + _log(f"bad regex {regex!r}: {e!r}") + continue + if not m: + continue + if "key" in m.groupdict() and m.group("key"): + ref = m.group("key") + else: + ref = m.group(0) + return provider, ref + return None + + +def post_link( + session_uuid: str, + ref: str, + provider: str, + session_slug: Optional[str], +) -> bool: + """POST the link to karma. Returns True on success; never raises.""" + url = f"{API_BASE}/sessions/{session_uuid}/tickets" + body: dict = { + "ref": ref, + "provider": provider, + "source": "branch", + } + if session_slug: + body["session_slug"] = session_slug + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SEC) + return True + except (urllib.error.URLError, OSError) as e: + _log(f"POST {url} failed: {e!r}") + return False + + +def main() -> None: + """Hook entry point. Never raises, never blocks SessionStart.""" + try: + raw = sys.stdin.read() + if not raw: + return + payload = json.loads(raw) + except (json.JSONDecodeError, OSError): + return + + session_uuid = payload.get("session_id") + cwd = payload.get("cwd") or "" + if not session_uuid: + return + + config = load_config() + if not config.get("branch_detect_enabled"): + return + + patterns = config.get("ticket_branch_patterns") or [] + if not patterns: + return + + branch = git_current_branch(cwd) + if not branch: + return + + matched = match_pattern(branch, patterns) + if matched is None: + return + + provider, ref = matched + slug = lookup_slug_from_live_sessions(cwd) + post_link(session_uuid, ref, provider, slug) + + +if __name__ == "__main__": + try: + main() + except Exception as e: # absolutely never block SessionStart + _log(f"unexpected error: {e!r}") + sys.exit(0) diff --git a/skills/link-ticket-to-session/SKILL.md b/skills/link-ticket-to-session/SKILL.md new file mode 100644 index 00000000..08155947 --- /dev/null +++ b/skills/link-ticket-to-session/SKILL.md @@ -0,0 +1,146 @@ +--- +name: link-ticket-to-session +description: Link the current Claude Code session to a ticket (Linear, Jira, GitHub Issues, or GitHub Pull Requests) and cache its title/status in karma. Use when the user explicitly asks to link, attach, associate, or connect this session to a ticket, issue, or PR — e.g. "/link-ticket-to-session ABC-123", "link this session to LINEAR-42", "associate this work with issue #15", "attach session to PR octocat/repo#7". Do NOT auto-invoke from passing ticket-key mentions in normal conversation. +argument-hint: +allowed-tools: Bash, mcp__linear, mcp__claude_ai_Linear, mcp__plugin_github_github, mcp__atlassian +--- + +You are linking the current Claude Code session (`${CLAUDE_SESSION_ID}`) to: **$ARGUMENTS** + +Karma is a read-only observer on the user's machine. It stores the link +and caches title/status for display, but never writes back to the ticket +provider. You fetch metadata via the user's already-configured MCP server. + +Karma's API URL comes from `KARMA_API_URL` (set by users on non-default +ports/hosts) with `http://localhost:8000` as fallback. Inline +`${KARMA_API_URL:-http://localhost:8000}` in **every** curl below — bash +variables don't persist across separate Bash tool calls, so a top-of-script +assignment would be empty by the time the next curl runs. + +## Step 1 — Parse $ARGUMENTS + +Recognized forms: + +| Provider | Short ref | URL forms | +|--------------------|---------------------|--------------------------------------------------------| +| Linear | `LINEAR-123` | `https://linear.app/.../issue/ABC-123` | +| Jira | `PROJ-45` | `https://*.atlassian.net/browse/PROJ-45` | +| GitHub **Issue** | `owner/repo#42` | `https://github.com/owner/repo/issues/42` | +| GitHub **PR** | `owner/repo#42` | `https://github.com/owner/repo/pull/42` | + +GitHub issues and pull requests share a single numbering namespace — +`owner/repo#42` could be either. **The URL kind (`/issues/` vs `/pull/`) +is the only signal**, and karma's backend preserves it, so when you have +a URL keep it intact when POSTing (Step 4). For a bare `owner/repo#N` +with no URL, default to `/issues/N` — GitHub auto-redirects to `/pull/N` +when N is actually a PR, so the link still resolves. + +A bare `#N` (no owner/repo) is **not** accepted — always qualify with +`owner/repo#N`. + +## Step 2 — Identify provider and (for GitHub) kind + +Set two variables you'll use below: + +- `` ∈ `linear` | `jira` | `github` +- For GitHub: `` ∈ `issue` | `pull_request` (derived from URL path) + +For Linear and Jira this collapses to just ``. + +## Step 3 — Fetch metadata via MCP (when available) + +Pick the right MCP tool for the provider and kind: + +| Provider · Kind | MCP tool | +|-----------------------------|---------------------------------------------------------| +| `linear` | Linear MCP — search/fetch issue by key | +| `jira` | Atlassian MCP — fetch by key | +| `github` · `issue` | `mcp__plugin_github_github__issue_read`, method `get` | +| `github` · `pull_request` | `mcp__plugin_github_github__pull_request_read`, method `get` | + +Calling the wrong GitHub method silently returns the wrong thing because +both shapes look superficially similar — so derive the kind first. + +If the relevant MCP isn't installed, **skip this step** and proceed to +Step 4 without title/status. Karma will create the link; the title/status +fields stay NULL and can be refreshed later via Step 5. + +Pull at minimum: `title`, `status` (or state), `url`. **Strip large +fields** — karma caps `metadata_json` at 64 KB and a full PR payload +easily exceeds that. Specifically drop: + +- GitHub PR: `body`, `commits`, `files`, `reviewers`, `comments`, `labels`, + `requested_reviewers`, `head` / `base` blobs beyond `ref` +- GitHub issue: `body`, `comments`, `reactions`, `labels` +- Linear / Jira: `description`, `comments`, `subscribers`, `attachments` + +### Status semantics by kind + +The `status` you cache should reflect *what the provider says now*, not a +generic "open/closed". Karma's UI normalizes these to canonical buckets +at render time, so faithful provider language is the right input: + +- **Linear**: workflow state name verbatim — e.g. `Backlog`, `In Progress`, + `In Review`, `Done`, `Cancelled` (workspace-defined; don't normalize). +- **Jira**: workflow state name — e.g. `To Do`, `In Progress`, `In Review`, + `Done`. +- **GitHub issue**: `open` or `closed`. +- **GitHub PR**: derive from the flags the PR API returns: + + | `state` | `draft` | `merged` | Cache as | + |----------|---------|----------|--------------| + | `open` | `true` | — | `draft` | + | `open` | `false` | — | `open` | + | `closed` | — | `true` | `MERGED` | + | `closed` | — | `false` | `closed` | + +## Step 4 — POST the link + +The `url` field should be the URL you actually have — `/pull/N` for PRs, +`/issues/N` for issues. **Don't rewrite it.** Karma's parser preserves +the path segment; the UI uses it to distinguish PRs from issues. + +```bash +curl -s -X POST "${KARMA_API_URL:-http://localhost:8000}/sessions/${CLAUDE_SESSION_ID}/tickets" \ + -H 'Content-Type: application/json' \ + -d '{"ref":"","provider":"","url":"","source":"slash_command"}' +``` + +For GitHub, `` is always `owner/repo#N` regardless of kind — the +URL field carries the issue/PR distinction. + +## Step 5 — PUT the metadata (only if Step 3 succeeded) + +```bash +curl -s -X PUT "${KARMA_API_URL:-http://localhost:8000}/tickets//" \ + -H 'Content-Type: application/json' \ + -d '{"title":"","status":"<status>"}' +``` + +For GitHub keys with `/` and `#`, URL-encode the key in the path: +`octocat/repo#42` → `octocat%2Frepo%2342`. + +## Step 6 — Confirm to the user + +One line. For GitHub, distinguish the kind so the user knows what they +just attached: + +- `Linked session to LINEAR-123 (Fix login bug, In Progress) — open at https://linear.app/...` +- `Linked session to PROJ-45 (Migrate auth, Done) — open at https://acme.atlassian.net/browse/PROJ-45` +- `Linked session to octocat/repo#42 [issue] (Empty state lies, open) — open at .../issues/42` +- `Linked session to octocat/repo#42 [PR] (Fix linting, MERGED) — open at .../pull/42` + +## Notes + +- Karma is loopback-only by default. `KARMA_API_URL` overrides for custom + port or remote host. +- POST is idempotent on `(session, ticket)`; re-running upgrades the + `link_source` if previously set by branch-detect or dashboard. Order: + `slash_command > dashboard > branch`. +- If the API is unreachable, tell the user + `karma not running at ${KARMA_API_URL:-http://localhost:8000}` so they + see what was tried. Don't silently succeed. +- GitHub issues and PRs sharing `#N` means a single karma row (one + `(provider, external_key)` pair) covers both views of that number. + The URL field is what tells karma's UI which one to render. Send the + URL you actually have.