Skip to content

feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)#73

Merged
JayantDevkar merged 32 commits into
mainfrom
feat/session-ticket-linking
May 19, 2026
Merged

feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)#73
JayantDevkar merged 32 commits into
mainfrom
feat/session-ticket-linking

Conversation

@JayantDevkar
Copy link
Copy Markdown
Owner

@JayantDevkar JayantDevkar commented May 18, 2026

Summary

Links Claude Code sessions to tickets (Linear, Jira, GitHub Issues) and surfaces them across the dashboard. Tickets attach to sessions via three paths — branch name auto-detect, slash command, and dashboard paste — and aggregate across all checkouts of the same git repo via a new git_identity column.

Closes #72.

What's in this PR

Backend (FastAPI)

  • Schema v11: tickets + session_tickets tables (idempotent CREATE, FK with cascade, partial unique index on slug, 64KB metadata cap)
  • Schema v12: projects.git_identity column populated at index time from git remote get-url origin, normalized to lowercase owner/repo
  • Endpoints: POST/GET/DELETE /sessions/{uuid}/tickets, GET/PUT/PATCH /tickets/{provider}/{external_key}, GET /tickets, GET /tickets/{provider}/{external_key}/sessions
  • Ticket aggregation: list_tickets(project=…) resolves project's git_identity and aggregates tickets across all sibling encoded_names sharing it (e.g., main clone + frontend subdir + worktrees all show the same tickets)
  • GitHub heuristic: tickets like owner/repo#42 surface under projects with git_identity=owner/repo even without a local link — supports cross-machine sync
  • Identity helper: safely_resolve_project() lets every endpoint accept either slug or encoded_name uniformly; applied to 12 endpoints (tickets, skills × 3, commands × 5, agents × 2, live_sessions)
  • Cleanup service: orphan-link TTL via cleanup_orphan_session_tickets
  • Live-session enrichment: ticket-sessions endpoint enriches with live-session state for unindexed sessions

Frontend (SvelteKit / Svelte 5)

  • /tickets: global tickets index (filterable, searchable)
  • /tickets/{provider}/{external_key}: ticket detail with linked sessions
  • ProjectTicketsTab: new tab on every project page showing tickets touched by sessions in that project (with cross-encoded aggregation)
  • SessionTicketsSection: ticket links inline on session pages with link/unlink UI
  • TicketBadge, TicketLinkInput: 5-state input component for paste/link UX
  • Route rename: [project_slug][project_id] — honest naming for the dual-form identifier
  • URL builder: projectHref() + projectHrefFromSession() centralize the slug || encoded_name policy (used by GlobalSessionCard, LiveSessions*, CommandPalette)
  • Design system: provider colors, 5-state input states, undo toast, Q9 tab placement

Hooks (Python / captain-hook)

  • ticket_branch_detector.py: SessionStart hook auto-detects ticket refs from git branch names (feat/ABC-123-foo → links to LINEAR-123)

Skill

  • link-ticket-to-session: slash-command-as-skill for manual linking via MCP-provided title/status. Honors KARMA_API_URL env var for non-default ports.

Tests

  • API: 1580 passed (full suite)
  • New: test_tickets.py (40 cases), test_schema_tickets.py (8 cases), test_ticket_parser.py, test_ticket_session_enrichment.py, test_git_identity.py (23 cases), test_project_identity_helpers.py (7 cases)
  • Hooks: test_ticket_branch_detector.py (299 LOC)

Bug fix included (b68e7fd)

After the initial implementation, surfaced a routing bug: navigating /projects → card → tickets tab showed empty because ProjectCard linked via slug while the tickets API only matched on encoded_name. The fix unifies the data flow with a clean architecture — slug at the boundary, encoded_name inside — covered by 7 new unit tests and a Playwright walk-through of both navigation paths.

Test plan

  • pytest from api/ — full suite passes
  • npm run check from frontend/ — type-check clean
  • Open /projects → click a project card → Tickets tab loads (slug URL form)
  • Open /tickets → click a ticket → click a linked session → Tickets tab on the project page loads (encoded_name URL form)
  • Subdir projects (e.g. claude-karma/frontend) show the same tickets as the main project (cross-encoded aggregation)
  • /link-ticket-to-session LINEAR-123 in a Claude Code session creates a link visible in karma
  • Push a branch named feat/ABC-123-foo and verify the hook auto-creates the link

🤖 Generated with Claude Code

JayantDevkar and others added 15 commits May 13, 2026 22:28
Design for linking Claude Code sessions to Linear/Jira/GitHub tickets in
karma. Read-only: karma stores links and caches metadata fetched by the
agent via MCP at link time. Three link paths (branch auto-detect, slash
command, dashboard). Many-to-many with no primary. New tables in schema v11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address all blockers/majors from oh-my-claudecode:critic and
feature-dev:code-reviewer:

- Split POST (link, idempotent) from PUT (metadata refresh) so each
  endpoint is properly idempotent in the HTTP sense
- Add session_slug column to session_tickets plus partial unique index
  to dedupe resumed-session links (sessions get new UUIDs per resume
  but share a slug)
- Define link_source precedence (slash_command > dashboard > branch)
  via CASE on conflict
- Wrap upsert in single transaction with RETURNING; cap metadata_json
  at 64 KB via CHECK constraint
- Reference correct write pattern: get_writer_db() from
  api/db/connection.py:45, not the nonexistent sync_queries.py
- Mandate adding tables to SCHEMA_SQL (fresh install) AND the v11
  migration block; v10 → v11 covers both paths
- Lock down slash-command session-UUID acquisition: agent reads
  ~/.claude_karma/live-sessions/ filtered by cwd + recency
- Remove impossible server-side git remote fallback; callers must
  fully qualify GitHub refs (owner/repo#N)
- Switch frontend routes to /tickets/[provider]/[external_key] so
  URLs survive a DB rebuild; aligns with repo's semantic-slug pattern
- Define ~/.claude_karma/config.json loader inline with opt-in default
  (branch_detect_enabled=False)
- Cite session_title_generator.post_title() as prior art for hook-to-
  karma POST pattern
- Add orphan cleanup policy, scaling notes, accepted-risks section
- Move hook tests to hooks/tests/ where the code lives

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice (a) of the session-ticket-linking feature (see
docs/superpowers/specs/2026-05-13-session-ticket-linking-design.md):
schema, models, parser, queries, router, and orphan-cleanup task.

Schema v11 (api/db/schema.py):
  tickets: (provider, external_key) unique registry with cached title/
  status and a 64 KB CHECK on metadata_json. session_tickets:
  many-to-many link table with partial unique index on (session_slug,
  ticket_id) WHERE session_slug NOT NULL — dedupes links across
  resumed sessions that share a slug but have different UUIDs. Added
  to both SCHEMA_SQL (fresh install) and the v10 → v11 incremental
  migration block.

Router (api/routers/tickets.py): 8 endpoints, mounted with no prefix
so it spans /sessions/{uuid}/tickets and /tickets/{provider}/{key}
roots. POST creates/upgrades the link (link_source precedence:
slash_command > dashboard > branch; never downgrades). PUT/PATCH
refresh cached metadata with COALESCE preservation so a degraded
MCP fetch never wipes prior data. external_key uses :path so
github-style 'owner/repo#42' routes correctly.

Queries (api/db/ticket_queries.py): upsert helpers handle both
unique constraints — same-UUID re-link AND same-slug resume — by
finding the existing row and upgrading link_source / filling slug,
rather than racing on INSERT OR IGNORE which couldn't disambiguate
the two unique indexes.

Parser (api/services/ticket_parser.py): pure URL/ref → TicketRef
function. Recognized: Linear/Jira/GitHub URLs, github short
owner/repo#N, bare ALPHA-N with explicit provider hint. Bare '#N'
deliberately unsupported (server has no git context).

Orphan cleanup (api/services/ticket_cleanup.py + api/main.py
lifespan): 6h background task removes session_tickets rows whose
session_uuid never materialized after 7 days, mirroring the
existing session_reconciler asyncio.create_task pattern.

Tests: 57 new tests across parser (26), schema (9), endpoints (22).
All 1474 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice (b) of the session-ticket-linking feature: minimal dashboard
linking flow on the session detail page.

Adds:
  - Ticket TS types in api-types.ts (Provider, LinkSource, Ticket,
    SessionTicketLink, SessionTicketRow, TicketListItem,
    CreateLinkRequest, CreateLinkResponse)
  - <TicketBadge> — three variants (inline/card/pill) with provider
    color tokens, external-link icon, optional remove button
  - <TicketLinkInput> — accepts URL or bare ref; auto-shows provider
    dropdown only when the input is an ambiguous bare key. Posts to
    /sessions/{uuid}/tickets and surfaces API hints on 400
  - <SessionTicketsSection> — wraps badges + input; local state seeded
    from initial prop via $state.snapshot to silence the reactive-prop
    warning (matches the SkillList idiom). Optimistic update on
    create; idempotent — re-linking same ticket replaces the row in
    place, otherwise prepends
  - Index barrel at lib/components/tickets/index.ts

Wiring:
  - +page.server.ts adds an 8th parallel fetch to
    /sessions/{uuid}/tickets via fetchWithFallback (empty array fallback)
  - +page.svelte renders <SessionTicketsSection> above ConversationView
    using session.uuid + session.slug for slug-based dedup

npm run check: 0 errors. npm run lint: no new warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice (c) of the session-ticket-linking feature: ticket-centric views
in the dashboard.

Backend:
  - GET /tickets gains a ?project=<encoded_name> query parameter. The
    list_tickets query adds a join to sessions when filtered so the
    project filter excludes orphan links (session_uuid not yet
    indexed). Endpoint test added covering project A vs project B
    isolation.

Frontend routes:
  - /tickets — index page. Table of tickets with provider · key ·
    title · status · session_count · last linked. Filter by provider
    via dropdown, search by key/title via URL-state-driven form,
    project filter shown as a removable chip when present.
  - /tickets/[provider]/[external_key] — detail page. Card view of
    the ticket metadata + linked-sessions list. Cross-links to
    /projects/{encoded_name}/{slug} for indexed sessions; marks
    orphans clearly when the session_uuid isn't in the sessions
    index yet.

Header:
  - "Tickets" link added to both desktop and mobile nav, between
    Sessions and Plans.

Verification:
  - 58 backend tests pass (incl. new project-filter test using a
    real sessions row inserted via the writer connection).
  - npm run check: 0 errors. New warnings are the same
    state_referenced_locally pattern already accepted in SkillList.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice (d) of the session-ticket-linking feature: SessionStart hook
that auto-links sessions to tickets when the current git branch
matches a configured pattern.

hooks/ticket_branch_detector.py — opt-in, silent on every failure.
  - Reads stdin JSON (session_id, cwd) from the SessionStart event
  - Loads ~/.claude_karma/config.json (defaults to disabled)
  - Runs `git symbolic-ref --short HEAD` in cwd
  - Matches branch against configured regexes (named 'key' group or
    full match)
  - Best-effort slug lookup via ~/.claude_karma/live-sessions/ —
    picks the entry with matching cwd and most-recent timestamp,
    feeds slug to the API for slug-based dedup across resumes
  - POSTs to /sessions/{uuid}/tickets via urllib.request, mirroring
    the prior-art pattern from session_title_generator.post_title()
  - All errors are caught and appended to
    ~/.claude_karma/logs/ticket_branch_detector.log; hook always
    exits 0 so it never blocks SessionStart

Opt-in by default: branch_detect_enabled=False until the user
explicitly opts in via config. Prevents surprise links on
personal-projects directories and contains regex blast radius.

hooks/tests/test_ticket_branch_detector.py — 17 tests across
subprocess integration (silent-exit scenarios) and direct-module
unit tests for pure helpers (git_current_branch, match_pattern,
lookup_slug_from_live_sessions, load_config).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice (e) — final piece of the session-ticket-linking feature.

commands/link-ticket-to-session.md: agent-driven slash command. Uses
${CLAUDE_SESSION_ID} directly (confirmed during the planning spike to
be a documented custom-command substitution), so no need to look up
the session UUID from ~/.claude_karma/live-sessions/. The agent
parses the user's ref, uses the appropriate MCP server (Linear,
Atlassian, GitHub) to pull title/status, then POSTs the link and
PUTs the metadata in two clean idempotent calls.

SETUP.md: new Tier 4 section walking users through installing the
slash command (symlink or copy into ~/.claude/commands/) and the
optional branch-detect hook with its ~/.claude_karma/config.json
opt-in pattern. Distinguishes karma's read-only role from the agent's
MCP-driven metadata fetch.

docs/superpowers/specs/...design.md: spec section on Path 1 updated
to drop the live-sessions UUID lookup that was needed before the
spike — ${CLAUDE_SESSION_ID} replaces it. Frontmatter and curl
examples match the shipped commands/link-ticket-to-session.md.

Verification across the full feature: 75 ticket tests passing
(22 endpoint + 26 parser + 9 schema + 17 hook + 1 backend filter),
ruff clean on all new files, frontend type-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design discussion: skills are the right shape because they can
be invoked two ways — via the slash form (/link-ticket-to-session)
AND via natural language ("link this session to LINEAR-123") — while
commands are only user-typed slash invocations. The narrow
description prevents auto-firing on passing ticket-key mentions
in normal conversation (we explicitly rejected fuzzy prompt
detection during brainstorming).

Changes:
  - commands/link-ticket-to-session.md → skills/link-ticket-to-session/SKILL.md
    (git rename; contents updated with skill-shaped frontmatter:
    `name`, narrower `description`, `argument-hint`, plus
    `allowed-tools: Bash, mcp__linear, mcp__claude_ai_Linear,
    mcp__plugin_github_github, mcp__atlassian` so the agent can't
    reach into unrelated tooling)
  - SETUP.md Tier 4 install instructions updated:
    symlink/copy is now directory-shaped under ~/.claude/skills/
  - Design spec updated: section heading and prose now say
    "skill" instead of "slash command". link_source enum value
    in the DB stays as `slash_command` (stable API contract; the
    skill is still invoked via the slash mechanism).

Verification:
  - 75 ticket tests still pass (no DB enum changes)
  - npm run check: 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug found by the live meta-test of the session-ticket-linking feature
(GH issue #72).

On a machine where the karma DB had been used on a parallel branch
whose linear SCHEMA_VERSION had advanced past ours (observed: v22),
the early-return version gate in ensure_schema()

    if current_version >= SCHEMA_VERSION:
        return

skipped our v11 migration block entirely. Result: `tickets` and
`session_tickets` tables never created → every ticket endpoint
returned 500.

Fix: extract the two ticket-table DDL blocks into a separate
constant `_TICKETS_SCHEMA_SQL` and run it UNCONDITIONALLY at the
top of ensure_schema() via `conn.executescript()`. All statements
use CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS so this
is a no-op when the tables already exist. SCHEMA_SQL also still
contains the ticket blocks (via string concat) for the fresh-install
path, so callers that only invoke the full SCHEMA_SQL script
continue to get every table.

Why this design instead of bumping SCHEMA_VERSION past the observed
maximum: that fixes one user but not the general case of cross-
branch DB drift. The unconditional idempotent approach makes the
ticket feature resilient to ANY future version drift, and the same
pattern can be applied to other features that need to survive
cross-branch DB sharing.

Adds api/tests/test_schema_tickets.py::test_ensure_schema_creates_
ticket_tables_on_cross_branch_higher_version — sets schema_version
to 99 directly, calls ensure_schema, asserts the ticket tables and
the partial-unique index exist afterwards. The recorded version
stays at 99 because we deliberately don't pretend we ran a
migration — we just ensure tables exist.

Verification: 76/76 ticket tests pass (75 + 1 new), 1474/1474
existing tests still pass, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Briefing document to hand off to claude.ai/design for a visual pass
on the ticket-linking surfaces shipped in this branch.

Contents:
  - Self-contained context (problem, goals, in-scope surfaces)
  - Real JSON data samples + edge cases (long titles, missing
    metadata, orphan links, mixed providers)
  - Design tokens already in use (from frontend/src/app.css)
  - Constraint list (Svelte 5, Tailwind 4, bits-ui, lucide; no new
    deps; WCAG AA; light + dark)
  - 8 open questions for design (provider visual language, empty
    state, detail-page hierarchy, input micro-interactions, section
    placement, unlink button weight, status display, mobile layout)
  - Non-goals (don't redesign nav, routes, data model, brand assets)
  - Deliverable format I want back (variants + rationale + Tailwind
    classes + tokens explicitly named + dark-mode parity + system
    spec pages for cross-cutting decisions)
  - Round-trip plan: what code returns to design after impl, then
    critique + polish + ship

Paired companion `2026-05-18-ticket-linking-ui-log.md` will be
created lazily as decisions land — one section per loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements UI surfaces 5a + 5b from the design discussion on
project↔ticket relationship. Data model stays Option A (derived
through sessions) — no schema change, karma stays a passive
observer. The relationship is m:n by nature (a ticket can touch
many projects via its linked sessions); these two surfaces make it
navigable from both directions.

5a. <ProjectTicketsCard> on /projects/[project_slug] Overview tab
  New `frontend/src/lib/components/tickets/ProjectTicketsCard.svelte`,
  sits between ActiveBranches and the Sessions section. Client-side
  $effect fetch against the existing `/tickets?project=<encoded>`
  endpoint (already shipped in slice c). Renders up to 8 ticket
  pills with click-through, an overflow chip ("+N more") that links
  to the filtered `/tickets?project=` view, and "View all" header
  link. Loading + empty + error states all handled. No change to
  +page.server.ts — kept the load function untouched.

5b. Sessions grouped by project on /tickets/[provider]/[external_key]
  Reshapes the existing sessions list with a $derived grouping by
  `project_encoded_name`. When a ticket touches ≥2 projects, a
  "Touched <proj A> (3) · <proj B> (1)" summary line appears next
  to the section heading. Each group has its own header (folder
  icon + project name + session count) above its sessions. Orphan
  links (session_uuid not yet indexed) bucket under a separate
  "Not yet indexed" group, always rendered last. Group order:
  most-sessions-first for real projects, orphan last.

Brief amendment
  §7.9 added to docs/design-briefs/2026-05-18-ticket-linking-ui.md
  asking design for two-or-three variants of (a) the project-page
  ticket surface and (b) the ticket-detail project breakdown, plus
  a cross-cutting question about hierarchy on the ticket detail
  page. Framed explicitly as a UI question — the data model
  question was resolved (derived stays).

Verification: `npm run check` 0 errors, all 23 ticket endpoint
tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…te input, undo toast, Q9 tabs

Drop-in replacement of the ticket UI per the design deliverables from
the 2026-05-18 iteration (see docs/design-briefs/iterations/2026-05-18/
for screenshots + the README from design's patch).

Decisions locked (full rationale in docs/design-briefs/2026-05-18-
ticket-linking-ui-log.md):

  Q1  Provider language        — Direction C (industry color + LIN/JIR/GH letter-mark)
  Q2  Empty state              — Variant A (terminal-flavored, teaches 3 link paths)
  Q3  Detail hierarchy         — V2 (stats-first hero, sessions main content)
  Q4  Link input               — full 5-state machine (idle/typing/validating/error/success)
  Q5  Session-section          — Variant A ($ tickets [N linked] terminal header)
  Q6  Unlink                   — kebab menu (Open/Copy/Unlink) + 5s undo toast
  Q7  Status display           — normalized dot + verbatim label
  Q9a Project ticket surface   — TAB on /projects/[slug] (Variant A)
  Q9b Sessions by project      — TAB row on detail page (Variant A, conditional ≥2 projects)

Deferred to next iteration: Q8 (mobile/responsive) — index still uses
overflow-x; not in critical path.

Code changes:

  frontend/src/app.css
    +11 new tokens × 2 (light + dark) — --provider-{linear,jira,github}{,-subtle}
    and --status-{todo,active,review,done,closed}. All other tokens
    design's patch uses (--success, --info, --shadow-md, .focus-ring,
    etc.) already existed.

  frontend/src/lib/ticket-helpers.ts                              (new)
    Single source of truth for PROVIDER_META, normalizeStatus,
    detectProviderFromRef (URL/short-ref auto-detection),
    isAmbiguousKey, formatRelative, projectDisplayName.

  frontend/src/lib/components/tickets/
    TicketBadge.svelte           — full rewrite: provider chip + status
                                    dot + kebab menu (pill variant)
    TicketLinkInput.svelte       — full rewrite: 5-state machine with
                                    rich below-input feedback. Local
                                    var renamed `state` → `phase` to
                                    avoid shadowing the $state rune.
    SessionTicketsSection.svelte — full rewrite: terminal header,
                                    optimistic remove + 5s undo toast
                                    with countdown
    ProjectTicketsCard.svelte    — removed (superseded by tab)
    ProjectTicketsTab.svelte     — new (Q9a A): project-scoped Tickets
                                    tab with search + empty state +
                                    table reused from /tickets design

  frontend/src/routes/tickets/+page.svelte
    Full rewrite — segmented provider filter with counts, grid table,
    Q2 A empty state.

  frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte
    Full rewrite — stats-first hero (Projects / Sessions / First seen /
    Metadata), Q9b A project tabs conditional on ≥2 projects, source
    tags (via /link / via branch / via paste).

  frontend/src/routes/projects/[project_slug]/+page.svelte
    Added Tickets tab between Memory and Analytics; removed the
    inline ProjectTicketsCard from Overview. validTabs updated.

.gitignore
    Negation rule: !docs/design-briefs/**/*.png so design iteration
    artifacts stay versioned with the feature even though *.png is
    ignored at the repo root.

docs/design-briefs/
    2026-05-18-ticket-linking-ui-log.md  (new) — full decision log
    iterations/2026-05-18/                (new) — 13 screenshots from
                                                  design + ticket-ui-
                                                  review.html shell.
                                                  ~12 MB.

Local fixes I made to design's patch during application:
  • TicketBadge: menuEl typed as HTMLSpanElement (was HTMLDivElement,
    type-check rejected the bind:this on the <span>).
  • TicketLinkInput: renamed `state` → `phase` (Svelte 5 rune name
    collision — `$state` rune shadowed by local var declaration).

Verification:
  npm run check          0 errors, no new warnings
  pytest ticket tests    76/76 pass (no backend changes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… loosen pill, fix contrast

After live-screenshot critique against impeccable:frontend-design
guidelines (see screenshots in docs/design-briefs/iterations/), six
calibration fixes:

F1. Rebuild ticket detail page hero (highest-leverage)
    Drop the 4-up metric grid (Projects/Sessions/First seen/Metadata)
    — the impeccable DON'T list calls out exactly this "big number,
    small label, supporting stats" template as AI-slop. Title is now
    the protagonist: 24-28px display, generous tracking. Small
    identifier row above (provider chip + monospace key + Open in
    GitHub button). One subtitle line below with the rollup —
    `• open · 1 session · 1 project · first seen just now · synced
    just now`. Sessions list takes over as main content immediately.

F2. Loosen pill cramping in TicketBadge.svelte
    Drop the max-w-[42ch] outer cap and max-w-[20ch] title truncation.
    Title moves to a hover `title=` tooltip on the pill wrapper —
    the kebab menu already provides "Open in Linear/Jira/GitHub" so
    the inline external-link icon next to the key is redundant when
    onRemove is set; hidden in that case. Pill is now: provider chip
    + monospace key + optional status + kebab. Three clean tokens.

F3. (Voice consistency) — verified no change needed.
    The terminal "$ tickets [N linked]" voice is already correctly
    scoped to in-session contexts (SessionTicketsSection) and empty
    states (/tickets when no rows). Catalog and detail pages use the
    quiet "Sentence [N]" voice. Rule is intentional.

F4. Replace OCC-1284 ghost data with generic placeholders
    OCC-1284 is a real-company project key — leaking it through
    empty-state examples implied tenant data. Replaced with:
    - `/link-ticket-to-session ABC-123`
    - `git checkout -b feat/ABC-123-…`
    - `https://linear.app/team/issue/ABC-123`
    In three files: /tickets+page.svelte, ProjectTicketsTab.svelte,
    TicketLinkInput.svelte.

F5. Provider chip foreground tokens + contrast audit
    Added --provider-{linear,jira,github}-fg tokens to app.css
    (3 mode blocks × 3 providers = 9 token additions). Light mode
    keeps white text everywhere. Dark mode flips:
    - --provider-linear-fg: #1a1c4e (dark navy on lightened violet)
    - --provider-jira-fg: #001e4d (dark navy on lightened blue)
    - --provider-github-fg: #0f172a (dark slate on silver)
    Previous dark-mode GitHub chip was white-on-silver — 1.4:1
    contrast, critical AA fail. Now all three chips clear AA at
    10px small text in both modes.

    ProviderMeta in ticket-helpers.ts gains fgVar field. TicketBadge
    providerChip snippet + 2 inline chip renders (index table, project
    tab table) updated to use `color: var({meta.fgVar})`.

F6. Map "open" status → "active" dot color (was "todo" / gray)
    normalizeStatus in ticket-helpers.ts: split `open` out of the
    todo cluster. GitHub `open` issues are actively trackable work,
    not stale backlog — render with the active (blue) dot, not the
    todo (gray) one. Backlog/triage/to-do still map to todo.

Verification:
  npm run check    0 errors, no new warnings
  59 ticket tests still pass
  Live screenshots at 1440x900 against running uvicorn+vite

Round 2 wins (visual):
  - Detail page reads as "designed by someone who thought about it"
    instead of "AI dashboard template"
  - Session-page pill is breathable, scannable
  - All ghost data replaced with canonical placeholders
  - Dark-mode chips legible at AA across all three providers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final polish pass after re-screenshotting and re-critiquing against the
impeccable:frontend-design guidelines. All issues found in R2 are now
NIT-level resolved.

1. Detail page subtitle no longer doubles "just now"
   Added isSyncedNearFirstSeen() helper — when metadata_updated_at is
   within 60s of first_seen_at (i.e., we just got both from MCP at link
   time), suppress the "synced X" half of the subtitle. Subtitle now
   reads "first seen just now" for a freshly-linked ticket instead of
   the redundant "first seen just now · synced just now".

2. Session-page pill now shows status inline
   SessionTicketsSection passes showStatus={true} to TicketBadge. Pill
   becomes [GH] [key] [• open] [⋯] — status visible without click-through.
   Pill stays one comfortable line; the loosened max-width from R2 holds.

3. Project Tickets tab heading demoted
   The redundant "Tickets touched by this project" h2 (tab is already
   labeled "Tickets") is replaced with a quiet muted text line. Count
   moved inline. Tab label is the heading; the body just gives context.

4. "Open in /tickets" reworded to "View all"
   Clearer affordance — the user knows they're on the project's tickets
   surface; "View all" promises the global view. ExternalLink icon
   preserved.

5. Detail-page sessions sort caption tightened
   "·  sorted by most recently linked" → "· sorted by most recently
   linked" (single space). Cosmetic.

Plus: dropped unused TicketIcon import from ProjectTicketsTab.

Verification:
  npm run check    0 errors, no new warnings
  Live screenshots r3-* confirm each fix

This closes the design iteration loop. The branch is at the state
documented in iterations/2026-05-18 plus two polish rounds (R2 commit
1e78d03 and this R3). PR #73 is ready to merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes two real bugs surfaced by live use of the ticket linking feature:

1. **Active sessions no longer show as orphans.** Linking ticket #72 to
   this Claude Code session used to render as "(orphan — not yet
   indexed)" because the `sessions` SQLite table is only populated by
   the JSONL indexer at SessionEnd. We now fall through to the
   live-sessions JSON files (written by hooks/live_session_tracker.py)
   for any row missing an indexed sessions entry. Active sessions
   render with their actual slug, project, started time, and a
   distinct status badge (ACTIVE / WAITING / STARTING / STALE).

2. **Tickets tab on session page** for parity with the project page.
   The strip above ConversationView is removed; Tickets is now a tab
   inside ConversationView, sitting between Commands and Analytics —
   matching the project page's tab order (Memory · Tickets · Analytics).
   Gated on main sessions only.

## Backend

NEW: `api/services/ticket_session_enrichment.py` (148 LoC)
  - `enrich_sessions_with_live(rows)` — mutates ticket-session-row
    dicts in place, adds a `live` block and backfills missing
    sessions-table fields from live state.
  - `_build_uuid_index` — two-pass: pass 1 indexes historical
    `session_ids[]` via setdefault; pass 2 overwrites with current
    `session_id`. Guarantees "current wins" regardless of input
    order, addressing a fragility flagged in code review.
  - `_live_block` — narrow projection of LiveSessionState into the
    response shape.
  - One filesystem scan per request via `load_all_live_sessions()`;
    O(N) lookup over result rows. Error-isolated — if live-sessions
    read fails, rows are returned unmodified.

MOD: `api/routers/tickets.py`
  - `list_sessions_for_ticket` now passes `get_ticket_sessions` output
    through `enrich_sessions_with_live`.

MOD: `api/models/live_session.py`
  - `load_all_live_sessions` now catches `OSError` and
    `UnicodeDecodeError` in addition to JSON errors, so a single
    corrupt or partially-written file no longer aborts the whole
    batch — important for the enrichment service's best-effort
    coverage.

NEW: `api/tests/test_ticket_session_enrichment.py` (11 tests)
  - Indexed row preserved, live block None.
  - Orphan row backfilled with live data; live block populated.
  - Resumed-session lookup via `session_ids[]` membership.
  - True orphan stays orphan.
  - Read failure doesn't break the pipeline.
  - Fast path (no missing rows) skips the directory scan entirely.
  - Indexed start_time not overwritten by live data.
  - Cross-state UUID collision: "current wins" in forward AND
    reverse input order (regression test for the ordering invariant).

## Frontend

NEW types in `api-types.ts`:
  - `LiveSessionMeta` — `status: LiveSessionState` (reuses existing
    union type), `started_at`, `last_updated`, `cwd`.
  - `TicketDetailSessionRow` — full row shape returned by
    `GET /tickets/.../sessions`, including the `live` field.
    Centralizes what was a local inline type in +page.server.ts.

MOD: `frontend/src/lib/components/conversation/ConversationView.svelte`
  - New `tickets: SessionTicketRow[]` prop (defaults to `[]`).
  - Tickets TabsTrigger between Commands and Analytics, count badge
    matches the Skills/Commands count-span pattern. Inside the
    `isMainSession` gate.
  - Tickets Tabs.Content mounts `<SessionTicketsSection>` with the
    seeded `initial` prop. Sessions without a UUID see a fallback
    "Session UUID unavailable" message instead of mounting the
    section (avoids undefined-cascade).
  - `validTabs` $derived now includes `'tickets'` for main sessions
    so `?tab=tickets` deep-links correctly.

MOD: `frontend/src/routes/projects/[project_slug]/[session_slug]/+page.svelte`
  - Removed the `<SessionTicketsSection>` strip that previously sat
    above ConversationView (Q5 A → tab-only consistency).
  - Passes `tickets` + `sessionUuid` through to ConversationView.

MOD: `frontend/src/routes/tickets/[provider]/[external_key]/+page.svelte`
  - New `liveBadge()` helper maps `LiveSessionState` → label+color.
  - Each-row block: new derived flags `isLive`, `isActive`,
    `isOrphan`, `slugLabel`, `navIdentifier`. Click-through href now
    falls back to UUID-prefix when slug is unavailable (live sessions
    early in their lifecycle don't have slugs assigned yet).
  - `activeCount` is now derived from the same live-aware logic as
    the per-row `isLive`, so the subtitle stat doesn't disagree with
    the visible list. STALE/STOPPED live sessions correctly count as
    ended.
  - True orphans render with "no data" suffix (different from
    "ACTIVE" / "live-but-no-slug").

MOD: `frontend/src/routes/tickets/[provider]/[external_key]/+page.server.ts`
  - Imports `TicketDetailSessionRow` from `api-types.ts` instead of
    re-declaring inline.

## Verification

  - 70 ticket tests pass (parser 26, schema 10, endpoints 23,
    enrichment 11). Adds 11 new tests.
  - npm run check: 0 errors.
  - ruff: clean.
  - Live smoke screenshot: session #72 (this one) renders with
    ACTIVE badge, "1 session (1 active) · 1 project · first seen
    just now" subtitle, click-through href.

## Process

This commit was produced via the feature-dev:feature-dev skill loop:
discovery → 2 parallel explorer agents (live-sessions infra +
ConversationView tab system) → 3 clarifying questions → architecture
proposal + sign-off → implementation → 3 parallel reviewer agents
(simplicity, correctness, conventions) → 5 review fixes applied →
verify → commit. See PR #73 description for the full thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JayantDevkar and others added 12 commits May 18, 2026 19:57
The homepage navigation grid had a low-traffic Archived card. Tickets
is a primary feature in this branch and deserves a home-row slot.
Swap one card in place:
  • Archived (History icon, gray)  →  Tickets (Ticket icon, amber)
  • Archived stays accessible via the header nav for users who want it.
  • Amber for the warm "active feature" signal; semantically tied to
    ticket / sticky-note convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small unrelated improvements:

1. /tickets page header
   Was: a plain h1 + monospace count + paragraph subtitle, inconsistent
   with the rest of the app (Sessions and Projects use <PageHeader>
   with icon-on-left + breadcrumb + monospace subtitle pill).
   Now: <PageHeader title="Tickets" icon={TicketIcon}
   iconColor="--nav-amber" breadcrumbs=[Dashboard / Tickets]
   subtitle="Linked across sessions · click through for the
   cross-project rollup"/>.
   The amber matches the homepage NavigationCard color so the user's
   visual handoff from "click Tickets card on home" → "land on
   /tickets" is consistent.

2. docs/design-briefs/2026-05-19-nav-header-redesign.md
   New brief for claude.ai/design covering the crowded top nav (12
   text labels at 1440px). Includes: problem statement, semantic
   grouping proposal (Work / Knowledge / Infra / Meta), 7 open
   design questions, constraints, and the existing token list. Same
   workflow pattern as the ticket-linking design brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the design returned from claude.ai/design for the crowded
navigation header (12 link items → wall of labels at 1440px). See
docs/design-briefs/iterations/2026-05-19/C-icon-first-compact.{png,html}
for the source artifacts.

The pattern, per design:
  - Inactive items: icon-only (4×4 lucide glyph), text-muted, `title`
    attribute provides the tooltip on hover. Halves horizontal footprint.
  - Active item: expands to icon + label inside a brand-tinted pill —
    background uses `--nav-{color}-subtle`, icon takes `--nav-{color}`,
    label is `--text-primary` for legibility. The pill is `px-2.5 py-1.5`
    while inactive items are `p-2`.

The brand colors per item mirror the homepage NavigationCard color
exactly, so a user clicking "Tickets" on the home grid → landing on
/tickets sees the same amber tint propagate to the active nav pill.
Continuity of identity across surfaces.

Implementation:
  - Refactored Header.svelte from 12 hand-copied <a> blocks to a
    single `NAV_ITEMS` array iterated with {#each}. ~140 lines saved.
  - Both desktop nav AND mobile drawer use the same array; mobile keeps
    text labels alongside icons (vertical space isn't constrained).
  - `aria-label` on every item for screen readers; `aria-current="page"`
    preserved on the active item; `title` attribute = tooltip text.
  - `focus-ring` class on the desktop pill so keyboard nav is visible.

Tradeoff (per design notes): steeper learning curve — new users have
to hover to learn glyphs. Cable/Webhook/Puzzle in particular are not
universally recognisable. Mitigation: tooltips kick in on hover; the
existing ⌘K command palette covers power-user search; the homepage
NavigationCard grid still shows full labels for first-time orientation.

Tokens used: `--nav-{color}`, `--nav-{color}-subtle`, `--text-muted`,
`--text-primary`, `--bg-muted`. No new tokens added.

Verification: `npm run check` 0 errors. Live screenshots on /sessions
(teal active pill) and /tickets (amber active pill) match the design
mock pixel-perfectly except for minor spacing differences explained by
the gap-0.5 we chose vs the gap-1 in the mock — kept tighter to give
even more breathing room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues reported on /commands:
  - The page-header icon was --nav-blue but the homepage card and
    new nav pill were both red → cross-surface inconsistency.
  - Even after fixing that, Tools/Sessions both used teal and
    Hooks/Tickets both used amber — colors weren't distinct.

Now every route has a unique color, and the homepage card ↔ nav pill
↔ page-header icon all match for every section.

Color logic (semantic clusters):
  WORK (cool, foundational)    Projects=blue · Sessions=teal · Analytics=green
  ASSETS (characterful)        Tickets=amber · Plans=yellow · Agents=purple · Skills=orange
  ACTION (runtime/exec)        Commands=red · Hooks=cyan (NEW)
  INFRA (plumbing)             Tools=indigo · Plugins=violet
  META                         Archived=gray

Changes:

  1. Add --nav-cyan + --nav-cyan-subtle to app.css (3 mode blocks: :root,
     :root[data-theme='dark'], prefers-color-scheme:dark fallback).
     Light: #0e7490 (dark cyan). Dark: #22d3ee (Tailwind cyan-400).

  2. NavigationCard.svelte gains 'cyan' in the color union + a
     colorConfig entry (gradient + glow tuned for cyan).

  3. Routes/+page.svelte (homepage):
       Tools: color="teal"  → color="indigo"
       Hooks: color="amber" → color="cyan"

  4. Header.svelte NAV_ITEMS array:
       Tools: color: 'teal'  → 'indigo'
       Hooks: color: 'amber' → 'cyan'

  5. Page headers (PageHeader iconColor):
       /commands: --nav-blue  → --nav-red   (the original bug fix)
       /tools:    --nav-teal  → --nav-indigo
       /hooks:    --nav-amber → --nav-cyan

Visual verification on /, /commands, /tools, /hooks: every active nav
pill matches its page-header icon AND its homepage card color. Zero
duplicates across the 12 routes.

Type-check clean. No new dependencies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tickets linked from a session in one local project now also appear in
the tickets tab of every sibling project that shares the same git
remote (worktrees, subdir CWDs, multiple clones). Previously each
encoded_name was its own island even when they all pointed at the
same repo on github.

- schema v12: adds projects.git_identity TEXT + idx, idempotent ALTER
  (handles the phantom column some DBs already have), nudges
  sessions.jsonl_mtime so the periodic indexer backfills on next pass
- services/git_identity.py: pure URL normalizer + `git remote get-url
  origin` shellout with 2s timeout, same defensive pattern as
  hooks/ticket_branch_detector.py
- indexer wires read_git_identity into _update_project_summaries
- ticket_queries.list_tickets resolves project -> git_identity once,
  then takes one of three paths:
    A) sibling aggregation: p.git_identity = target
    B) github heuristic: external_key LIKE '{identity}#%' surfaces
       tickets linked on peer machines even with no local link
    C) per-encoded fallback when target git_identity is NULL
- link-ticket-to-session skill: hardcoded localhost:8000 replaced with
  inline \${KARMA_API_URL:-http://localhost:8000} per curl (bash state
  doesn't persist across Claude Code Bash calls, so per-call is correct)
- tests: 23 unit (URL parser + real-repo integration) + 4 endpoint
  (paths A/B/C + sibling count aggregation). Full suite: 1572 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard had two competing project identifiers (slug, encoded_name)
used inconsistently across the stack: ProjectCard linked via slug,
SessionCard via encoded_name, several components inlined a
`slug || encoded_name` fallback, and 12 API endpoints did raw exact-
match on encoded_name. Bug surface: /projects -> card -> tickets tab
showed empty because the URL carried a slug ('claude-karma-1044')
which the tickets API couldn't match.

Architectural principle now applied: slug at the boundary,
encoded_name inside.

API:
- New `safely_resolve_project()` helper in routers/projects.py — filter-
  friendly variant of `resolve_project_identifier` that returns None on
  None, returns input verbatim on unknown (so filter endpoints yield
  empty lists instead of 404), else returns canonical encoded_name.
- Applied to all 12 endpoints that take a project identifier:
  tickets (1), skills (3 + FS fallback), commands (5), agents (2),
  live_sessions (1).
- Module-level imports throughout (verified no circular path).

Frontend:
- Route renamed `[project_slug]` -> `[project_id]` to reflect honest
  "either form accepted" semantics. All `params.project_slug`,
  `data.project_slug`, and route-id strings updated.
- ProjectTicketsTab now receives `data.project.encoded_name` (resolved)
  instead of `$page.params.project_id` (raw URL form) — prop matches
  its name again.
- Session and agent pages thread `sessionLookup.project_encoded_name`
  through to `ConversationView.encodedName` (fixes a parallel bug
  where the session page was propagating a slug as 'encoded name').
- Fixed pre-existing typo: SessionLookupResult.project_project_slug
  was unreachable; renamed to project_encoded_name to match the API.
- New helper `frontend/src/lib/utils/project-url.ts` —
  `projectHref()` + `projectHrefFromSession()` centralize the
  `slug || encoded_name` policy. Replaced 4 inline fallbacks.

Tests:
- `tests/test_project_identity_helpers.py` (new): 7 unit tests for
  both resolution helpers covering None, empty, encoded_name,
  known slug, and unknown identifier branches.
- `tests/api/test_tickets.py`: regression test
  `test_project_filter_accepts_slug_or_encoded_name` locking in the
  contract that ?project= accepts either form.
- Full suite: 1580 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace 5 inline `/projects/{encoded_name}/{session_slug}` template
strings with `projectHrefFromSession()` from $lib/utils/project-url.
Brings the plans page and ConversationOverview's continuation link in
line with the policy already applied to GlobalSessionCard, LiveSessions*,
and CommandPalette.

Files:
- src/routes/plans/[slug]/+page.svelte: 4 sites (header link + 3 in
  Related Sessions list)
- src/lib/components/conversation/ConversationOverview.svelte: 1 site
  (continuation session link)

Left as-is:
- src/routes/agents/[name]/+page.svelte:624 — loop variable is a raw
  encoded_name string from a dict key, no session object available;
  the route accepts either form so `/projects/{project}` works.
- Prop-passing components (SessionCard, ConversationView,
  ConversationHeader, TimelineRail) — parent already resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two design briefs that have been sitting in docs/features/ untracked.
Both pre-date the session-ticket linking work but were never committed.
Pattern follows existing docs/superpowers/specs/ briefs (e.g. the
ticket-linking design that landed earlier in this branch).

- agent-walkie-talkie.md (828 lines): feature scope for inter-agent
  communication during long-running tasks.
- sync-v4.md (603 lines): Session Sync v4 design. Note: the sync code
  itself is NOT in this branch — design only. Sync prototype artifacts
  (sync_* tables, sync-imported project rows) were cleaned out of the
  local karma DB in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote the session ↔ ticket linking work to a tagged release.

Versions:
- api/pyproject.toml: 0.1.0 → 0.2.0 (minor — new feature, no breaking API)
- frontend/package.json: 0.0.1 → 0.2.0 (parity with api)

New files:
- CHANGELOG.md: Keep-a-Changelog format. v0.2.0 entry documents Added /
  Changed / Fixed / Internal sections plus an "Upgrading from 0.1.x"
  section that explains the v11 → v12 schema migration is automatic
  and idempotent, with a one-shot SQL snippet for users cleaning up
  syncthing-prototype leftovers.

Doc updates:
- README.md: new "Ticket Linking" section in Features. Lists the three
  link paths (paste / slash command / branch auto-detect) and what users
  see on /tickets, the ticket detail page, and the project Tickets tab.
- FEATURES.md: full "Tickets" section under the TOC. Documents both
  routes, the three link paths in a table, link-source precedence
  (slash_command > dashboard > branch), the git_identity aggregation
  semantics, the GitHub-key heuristic, schema, and orphan cleanup.
- SETUP.md:
  * Reframed Tier 4 from "Ticket Linking" to "Auto-Link Tickets" — the
    /tickets page and dashboard linking UX are Tier 1, only the
    workflows (skill + branch hook) are Tier 4.
  * Added /tickets to the Tier 1 features table.
  * Tier 4 now covers KARMA_API_URL, the "works without MCP" fallback,
    a verification checklist, branch detector entry in the All-Tiers
    hook config reference, and a removal recipe.
  * Updating section now documents the schema-version table (v7 → v12)
    and includes the syncthing-leftover cleanup SQL.

Verification:
- pytest from api/: 1580 passed
- npm run check from frontend/: 0 errors
- Live DB upgrade-from-v11 path tested manually: ensure_schema correctly
  bumps to v12, recreates idx_projects_git_identity, and ticket
  aggregation continues to return the same results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub uses one numbering namespace for issues and pull requests but two
URL paths (/issues/N vs /pull/N) and two semantic models — PRs carry
draft/merged state that issues don't. The parser was collapsing both to
/issues/N at write time, silently telling users that their PRs were
issues. The /tickets UI then had no way to distinguish them.

Root cause (not the surface):
ticket_parser.py treated all three providers asymmetrically — Linear and
Jira branches preserve the input URL verbatim (`url=s`), but the GitHub
branch rebuilt the URL from regex captures and hard-coded `/issues/` in
the format string. The regex correctly accepted `(issues|pull)` but
discarded which one matched. Restoring the parser's own preservation
pattern is the fix; the bug-locking test
(`test_github_url_returns_canonical_form_even_for_pull_url`) is replaced
with tests that encode the correct behavior.

Parser changes:
- `_GITHUB_URL` regex now captures `(?P<kind>issues|pull)` as a named
  group; the canonical URL is built using that capture instead of
  hard-coded `/issues/`. Noise (query, fragment, trailing path) is still
  stripped.
- `_GITHUB_SHORT` (bare `owner/repo#N`) continues to default to
  `/issues/N` because the input doesn't tell us the kind — GitHub
  auto-redirects /issues/N to /pull/N when N is a PR, so the link
  still resolves. Comment makes this explicit.
- Replaced 1 misleading test, added 4 new tests covering both forms +
  query stripping + the bare-key default.

Frontend changes:
- New helper `githubKindFromUrl(url)` derives `'issue' | 'pull_request'`
  from a github URL. Docstring explains why we don't add a schema column
  (Linear/Jira have no subtype concept; the URL itself is sufficient;
  if future kanban status taxonomy needs queryability, that PR can add
  a column).
- New component `ProviderChip.svelte` is the single source of truth for
  the provider letter-mark (LIN/JIR/GH) and optional `🔀 PR` indicator.
  Extracted because the chip was inlined at four sites — the original
  bug would have to be patched at each, with high risk of the indicator
  being silently omitted at one. Now there's one component to keep
  honest.
- Migrated TicketBadge, ProjectTicketsTab, /tickets index, and ticket
  detail header to use ProviderChip. Inline chip markup removed.
- Added 5 vitest cases for `githubKindFromUrl` covering /pull/, /issues/,
  query+fragment, null/empty/unrecognized URLs, and a false-match guard.

Existing #36 row repair:
A pre-existing row in the local DB had `url=/issues/36` but `status=MERGED`
(internally inconsistent — MERGED is unique to PRs). Repaired manually
via UPDATE statement; backup at
~/.claude_karma/backups/tickets-pre-pr-url-fix-*.sql. Not part of this
commit because it's a one-shot data fix, not code.

Verified:
- api: 1583 passed (was 1580)
- frontend: 266 passed (was 261), 0 type errors
- Playwright: /tickets index shows `🔀 PR` next to GH chip for #36 and
  #73 (the two PRs), plain GH for #40-#42 and #72 (issues), unchanged
  for Linear DE-246 / INF-83 / INF-88

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion brief to the 2026-05-18 ticket-linking UI brief. Documents the
intended kanban-shaped board view for /tickets, pre-resolves the
contested decisions (toggle not replace; 5 canonical columns matching
existing StatusKey; no drag-and-drop because karma is read-only), and
leaves seven variant questions for Claude Design to answer with rendered
HTML/Tailwind alternatives. Includes ASCII mockups for three direction
options (Terminal Kanban, Editorial Inbox, Status Strip).

Self-contained: pasteable into a fresh claude.ai/design conversation
as the sole context. No code changes — implementation follows once
variants are picked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 review surfaced four cleanups on commit 4597711; addressing
all of them so the fix isn't itself patchy.

1. Drop vestigial `providerChip` snippet in TicketBadge.svelte.
   After ProviderChip was extracted, the `{#snippet providerChip()}`
   wrapper was left in place, taking no parameters and doing nothing
   except relay to `<ProviderChip {ticket} />`. Snippets earn their
   keep when they take params or have conditional structure — this
   one had neither. Removed the snippet; inlined `<ProviderChip
   {ticket} />` at the three callsites (card, inline, pill variants).

2. Harden `githubKindFromUrl` against query-string false-positives.
   The old regex `/\/pull\/\d+(?:[/?#]|$)/i` ran against the raw URL
   string, so a URL like `…/issues/1?file=/pull/9` would have
   incorrectly returned 'pull_request'. The test claimed broader safety
   than the implementation provided. Switched to `new URL(url).pathname`
   and tightened the regex to require the `pull/N` after owner/repo
   segments. Added a dedicated regression test for the query-string
   case and a malformed-URL test (helper now catches and falls back to
   'issue' instead of throwing).

3. Test file conventions: added the `// ===` banner before the
   `describe` block to match every other test in `frontend/src/tests/`
   (api-fetch, grouped-projects, utils, tasks all use the same shape).

4. Document the backward-compat story for stale PR rows in
   pre-v0.2.0 DBs (CHANGELOG) + provide a self-service repair:

   New endpoint `POST /admin/repair-github-urls`. Conservatively
   rewrites `/issues/N` URLs to `/pull/N` for github tickets whose
   status is `MERGED` (a state unique to PRs — issues only have
   open/closed). Open or closed-unmerged PRs stay ambiguous from
   cached data alone and self-heal on next re-link. The endpoint is
   idempotent and reports `{"rewritten": N}`. Backed by 4 endpoint
   tests covering the happy path, the "leave unambiguous issues
   alone" guarantee, idempotency, and the github-provider scoping.

Verified:
- api: 1587 passed (was 1583 → +4 admin tests)
- frontend: 268 passed (was 266 → +2 helper tests, false-positive +
  malformed-URL regressions)
- type-check: 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI breakages introduced by the earlier root-cause fix work:

1. Python Lint (ruff I001) — module-level imports of
   `safely_resolve_project` in routers/agents.py, routers/commands.py,
   and routers/skills.py were inserted without re-sorting the surrounding
   import block. Ruff's import-sort rule flagged them. Fix: `ruff check
   --fix` reordered the imports. Affects agents.py, commands.py,
   skills.py, plus a stray blank-line cleanup in test_git_identity.py.

2. Python Tests (3.10 / 3.11 / 3.12) — `tests/test_project_identity_helpers.py`
   imports `fastapi` at module top to test `resolve_project_identifier`
   which raises HTTPException. But the unit-test CI job runs with
   `pytest tests/ --ignore=tests/api/` AND doesn't install fastapi
   (only the models package's deps). Collection failed with
   `ModuleNotFoundError: No module named 'fastapi'`.

   Fix: moved the test to `tests/api/test_project_identity_helpers.py`.
   The router-layer code it tests (resolve_project_identifier,
   safely_resolve_project from routers/projects.py) genuinely belongs
   under tests/api/ — the file's placement at the tests/ root was a
   judgment slip during the original commit, not a deliberate choice.

Verified locally with the exact CI commands:
- `ruff check models/ routers/ tests/` → All checks passed
- `pytest tests/ --ignore=tests/api/` → 1161 passed (unit-test job)
- `pytest tests/` → 1587 passed (full integration job)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous CI fix addressed `ruff check` (the linter) but not
`ruff format --check` (the formatter), which is a separate step in
`.github/workflows/api-tests.yml`. CI reported "Python Lint: fail"
despite linter passing because the formatter found 9 files that
needed reformatting.

The reformatted files include four written/modified during the recent
ticket-linking work plus five untouched-by-me files that were already
formatter-divergent in main. Running `ruff format` once normalizes
the lot.

Verified locally with the exact CI commands:
- `ruff check models/ routers/ tests/` → All checks passed
- `ruff format --check models/ routers/ tests/` → 91 files already formatted
- `pytest tests/ --ignore=tests/api/` → 1161 passed
- `pytest tests/` → 1587 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JayantDevkar and others added 3 commits May 19, 2026 09:55
…kill

The /tickets and project Tickets-tab empty states told first-time users
"the skill is installed by default" — which is false. The skill ships
in the karma repo at skills/link-ticket-to-session/ but only becomes
discoverable to Claude Code after Tier 4 symlinks it into
~/.claude/skills/. Users who saw that copy would type
`/link-ticket-to-session ABC-123` in a session and get nothing.

Three changes:

1. New shared component TicketEmptyState.svelte. Both empty states
   (frontend/src/routes/tickets/+page.svelte and
   frontend/src/lib/components/tickets/ProjectTicketsTab.svelte) were
   inlining the same ~40 lines of markup with one-word copy
   differences. Extracted to a single component with a `scope` prop
   ('global' | 'project') that toggles the small per-context copy
   variations. Removes ~80 lines of duplication.

2. Card order is now friction-ascending. Dashboard paste — the only
   path that works out of the box with zero setup — leads. Slash
   command (one-time install) is second. Branch hook (opt-in, requires
   config) is last. Previously the order led with the highest-friction
   path (slash command labelled "Fastest"), giving new users the worst
   first-run UX.

3. Card #2 (slash command) now carries an inline install block with
   both the symlink and the cp -R variants from SETUP.md → Tier 4.
   Users see the exact commands they need without leaving the empty
   state. Card badges (WORKS OUT OF THE BOX / ONE-TIME SETUP / OPT-IN
   HOOK) make the setup expectation explicit at a glance, and a footer
   link points to SETUP.md → Tier 4 for the full doc.

Verified:
- frontend type-check: 0 errors
- frontend tests: 268 passed
- Playwright: empty state renders correctly with all three cards,
  badges, install commands, and SETUP.md link — screenshot at
  docs/screenshots/2026-05-18-git-identity-tickets/16-empty-state-project-tab.png

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-world usage of the skill in this branch exposed three gaps that
the previous SKILL.md didn't address:

1. **GitHub URL kind not surfaced.** The skill listed GitHub URLs as
   /issues/N only. PRs use /pull/N, and karma's parser (fixed in
   4597711) now preserves that distinction. If the agent doesn't know
   to keep the URL kind, it sends a /issues/ URL for a PR and the UI
   shows it as an issue. Step 1 now has a 4-row table making both
   issue and PR URL forms explicit, with a note that the URL kind is
   the only signal between the two.

2. **Which GitHub MCP tool to call wasn't specified.** The MCP exposes
   `issue_read` and `pull_request_read` as separate tools. The agent
   had to guess. Step 3 now has a provider · kind → MCP tool table so
   the agent picks correctly. Calling the wrong one returns the wrong
   shape silently.

3. **PR status semantics were hidden.** GitHub PRs have draft + merged
   flags that don't map cleanly to issue-style open/closed. Step 3 now
   includes a small flag-truth-table mapping `(state, draft, merged)`
   triples to the canonical status string to cache (draft / open /
   MERGED / closed). Linear and Jira keep their workspace-defined
   workflow state names verbatim.

Other tightenings:
- Description includes "GitHub Pull Requests" explicitly so it triggers
  on PR-flavored requests, not just issues.
- The 64KB metadata cap warning now lists which fields are big per
  provider (PRs' commits/files/reviewers arrays are the usual culprits).
- Step 4 explicitly says "don't rewrite the URL kind when POSTing" —
  important because karma's backend now preserves it.
- Step 6 examples include both [issue] and [PR] confirmation lines so
  the user sees the kind in the slash-command output.

Name kept as `link-ticket-to-session`:
- "Ticket" is the umbrella term that already covers Linear's issues,
  Jira's stories/tasks/epics, and both GitHub issues and PRs.
- Renaming to `link-issue-or-pr` would over-index on GitHub vocabulary
  and break every existing symlinked install.
- Karma's data model is `tickets`, not `issues_or_prs` — name should
  align with storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When GitHub is the active provider on /tickets, a secondary sub-pill
row appears showing `[All N] [Issues M] [PRs K]`. The kind sub-filter
narrows the visible tickets to issues, PRs, or both. URL state lives
at `?kind=issue|pull_request`. The sub-pill row is hidden when any
other provider (or All) is active — kind is a GitHub-specific concept
and surfacing it elsewhere would be dishonest.

Design choice: Design A from the brief — progressive disclosure under
GitHub. Rejected B (five top-level chips, asymmetric, long row) and C
(independent Type dimension, conceptually muddy because "Issues" would
have to span Linear/Jira/GH-issues).

Filter logic is client-side, deliberately. The backend doesn't gain a
new query param — kind is derivable from the ticket URL via the
existing `githubKindFromUrl()` helper, and the ticket list is short
enough that a `.filter()` per render is free. This keeps the diff to
one frontend file (plus a one-line addition to +page.server.ts to
surface kind as URL state), preserves the cross-machine sync property
of all filter state living in the URL, and matches how `q` / project
/ provider already filter via `data.tickets` post-load.

Implementation details:

- New URL param `kind` ∈ '' | 'issue' | 'pull_request'. Added to
  +page.server.ts under `filters` so links remain shareable.
- `setProvider(next)` helper replaces the old inline onclick. When the
  user switches away from GitHub, it drops `kind` from both local
  state and the URL — kind only applies under GitHub, leaving it
  stuck in the URL would be a latent bug.
- `setKind(next)` is a small mirror for the sub-pill clicks.
- `visibleTickets` is the new `$derived` that filters `data.tickets`
  client-side when kind is set. The `{#each}` loop and the
  no-results state both switch to `visibleTickets`.
- `githubKindCounts` is a separate `$derived` for the sub-pill badges,
  computed against the full `data.tickets` so the counts stay sane
  even when filtering is active.
- The sub-pill row uses a smaller chip size (px-2.5 py-0.5 vs
  px-3 py-1, 11px vs 12px) and a `CornerDownRight` glyph for the
  visual indent — matches the "under GitHub" semantic without being a
  full second row.
- hasFilters now includes `kind` so the empty-state heuristic still
  works.

Verified:
- frontend type-check: 0 errors
- frontend tests: 268 passed (unchanged)
- Playwright: All / Linear / Jira / GitHub / GH+Issues / GH+PRs flows
  render correctly; sub-pills appear only under GitHub; counts match.
  Screenshots at docs/screenshots/2026-05-18-git-identity-tickets/17-19.

Deferred (per design brief, follow-up PR):
- Same sub-filter on the ProjectTicketsTab. Project tab today only
  has a search box, no provider chips, so adding kind there would
  also add a provider filter and grows scope. Worth doing once the
  filter pattern is proven on the global view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JayantDevkar JayantDevkar merged commit f4783fd into main May 19, 2026
20 checks passed
@JayantDevkar JayantDevkar deleted the feat/session-ticket-linking branch May 19, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: session ↔ ticket linking (Linear / Jira / GitHub Issues)

2 participants