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 `(?P
` 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
\ 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":""}'
+
+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[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; `` 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]`).
+
+`` 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).
+
+`` (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 @@
+ 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 @@
-
+
@@ -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}
>
@@ -201,116 +197,28 @@
role="presentation"
>
{/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}
- 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"
>
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}
+ Tickets
+ {#if tickets.length > 0}
+ {tickets.length}
+ {/if}
+
{/if}
Analytics
@@ -1162,6 +1179,22 @@
/>
{/if}
+
+
+
+ {#if sessionUuid}
+
+ {:else}
+
+ Session UUID unavailable.
+
+ {/if}
+
{/if}
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 @@
+
+
+
+
+
+ Tickets touched by any session in this project
+ {#if tickets}
+
+ · {filtered.length}{q && tickets.length !== filtered.length ? ` of ${tickets.length}` : ''}
+
+ {/if}
+
+
+ View all
+
+
+
+
+ {#if error}
+ Couldn't load tickets: {error}
+ {:else if tickets === null}
+ Loading…
+ {:else if tickets.length === 0}
+
+ {:else}
+
+
+
+
+
+ Provider
+ Ticket
+ Status
+ Sessions
+ Last linked
+
+
+ {#each filtered as t (t.id)}
+ {@const norm = normalizeStatus(t.status)}
+
+
+
+
+
+
+
+ {t.external_key}
+
+
+ {#if t.title}
+ {t.title}
+ {:else}
+ title not yet fetched
+ {/if}
+
+
+
+ {#if t.status}
+
+ {t.status}
+ {:else}
+ —
+ {/if}
+
+
+
+
+ {t.session_count}
+
+
+
+ {formatRelative(t.last_linked_at)}
+
+ {/each}
+
+ {#if filtered.length === 0}
+
+ No tickets match your search.
+
+ {/if}
+
+ {/if}
+
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 @@
+
+
+
+
+ {meta.short}
+
+ {#if showKind && isPullRequest}
+
+
+
+ PR
+
+ {/if}
+
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 @@
+
+
+
+
+
+
+
+ $ tickets
+
+
+ [{tickets.length} linked]
+
+
+
+
+ {#if tickets.length > 0}
+
+ {#each tickets as ticket (ticket.id)}
+ -
+
requestUnlink(ticket)}
+ />
+
+ {/each}
+
+ {/if}
+
+ {#if pending}
+
+
+
+ Unlinked
+ {pending.ticket.external_key}
+
+ · undo in {secondsLeft}s
+
+
+ {/if}
+
+ {#if tickets.length === 0 && !pending}
+
+
+ Paste a URL, key, or
+ owner/repo#N
+ below to link this session.
+
+ {/if}
+
+
+
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 @@
+
+
+
+
+{#snippet statusDot(size = 6)}
+
+{/snippet}
+
+{#if variant === 'card'}
+
+
+
+ {#if onRemove}
+
+ {/if}
+
+ {#if ticket.title}
+ {ticket.title}
+ {/if}
+ {#if showStatus && norm.verbatim}
+
+ {@render statusDot(7)}
+ {norm.verbatim}
+
+ {/if}
+
+
+{:else if variant === 'inline'}
+
+
+
+ {ticket.external_key}
+
+ {#if ticket.title}
+ — {ticket.title}
+ {/if}
+
+
+{:else}
+
+
+
+
+ {ticket.external_key}
+ {#if !onRemove}
+
+ {/if}
+
+ {#if showStatus && norm.verbatim}
+
+ {@render statusDot(6)}
+ {norm.verbatim}
+
+ {/if}
+ {#if onRemove}
+
+
+ {#if menuOpen}
+
+
+
+
+
+
+ {/if}
+
+ {/if}
+
+{/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 @@
+
+
+
+
+ $ tickets
+ {headerSuffix}
+
+
+
+
+ {headline}
+
+ {subhead}
+
+
+
+ {#each LINK_PATHS as row, i (row.n)}
+ 0}
+ class:border-[var(--border-subtle)]={i > 0}
+ >
+
+
+ {row.n}
+
+
+
+ {row.title}
+
+ {row.badge}
+
+
+ {row.sub}
+
+
+ {row.cmd}
+
+
+
+
+ {#if row.install}
+
+
+ {row.install.heading}
+
+ {#each row.install.commands as command}
+
+ {command}
+
+ {/each}
+
+
+
+ {/if}
+
+ {/each}
+
+
+
+
+
+ Karma is read-only — the ticket lives in its source of truth.
+
+
+ SETUP.md → Tier 4
+
+
+
+
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 @@
+
+
+
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 = {
+ 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 = {
+ 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/`). 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/` 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';
@@ -28,11 +28,11 @@
-
-
+
+
-
+
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 @@
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 @@
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}
@@ -85,8 +88,10 @@
@@ -103,7 +108,7 @@
{:then relatedSessions}
{#each relatedSessions as session}
@@ -134,7 +139,7 @@
{#each relatedSessions as session}
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(fetch, `${API_BASE}/projects/${params.project_slug}?${projectParams}`),
+ safeFetch(fetch, `${API_BASE}/projects/${params.project_id}?${projectParams}`),
fetchWithFallback(
fetch,
- `${API_BASE}/projects/${params.project_slug}/branches`,
+ `${API_BASE}/projects/${params.project_id}/branches`,
emptyBranches
),
fetchWithFallback(
fetch,
- `${API_BASE}/history/archived/${params.project_slug}`,
+ `${API_BASE}/history/archived/${params.project_id}`,
emptyArchived
),
fetchWithFallback(
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 @@
Project Skills
Project Tools
Project Memory
+ Tickets
Analytics
{#if archived.total_sessions > 0}
@@ -1725,6 +1728,13 @@
+
+
+ {#if project?.encoded_name}
+
+ {/if}
+
+
{#if archived.total_sessions > 0}
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(
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>(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(fetch, `${API_BASE}/sessions/${sessionUuid}/plan`)
+ safeFetch(fetch, `${API_BASE}/sessions/${sessionUuid}/plan`),
+ fetchWithFallback(
+ 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]'
);
@@ -39,7 +41,7 @@
Failed to Load Session
{error}
@@ -50,8 +52,9 @@
{:else}
{/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(
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]'
);
@@ -38,7 +38,7 @@
Failed to Load Agent
{error}
@@ -49,7 +49,7 @@
{:else}
{
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(
+ 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 @@
+
+
+
+ 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'}
+
+
+
+ {#each GH_KINDS as opt (opt.id)}
+ {@const kActive = kind === opt.id}
+ {@const kCount =
+ opt.id === ''
+ ? githubKindCounts.all
+ : opt.id === 'issue'
+ ? githubKindCounts.issue
+ : githubKindCounts.pull_request}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if data.filters.project}
+
+ Filtered to project:
+ {data.filters.project}
+
+
+ {/if}
+
+ {#if visibleTickets.length === 0}
+
+ No tickets match your filters.
+
+ {:else}
+
+
+
+ Provider
+ Ticket
+ Status
+ Sessions
+ Last linked
+
+
+ {#each visibleTickets as t (t.id)}
+ {@const norm = normalizeStatus(t.status)}
+
+
+
+
+
+
+
+ {t.external_key}
+
+
+ {#if t.title}
+ {t.title}
+ {:else}
+ title not yet fetched
+ {/if}
+
+
+
+ {#if t.status}
+
+ {t.status}
+ {:else}
+ —
+ {/if}
+
+
+
+
+ {t.session_count}
+
+
+
+ {formatRelative(t.last_linked_at)}
+
+ {/each}
+
+ {/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'}
+
+
+
+
+
+
+ {#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":""}'
+```
+
+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.