# Plan: User Identity + Repo Tracking Per Session

## Context

Every session in qc-trace currently has no concept of WHO is working or WHAT repo they're in. Sessions are just bags of messages grouped by session_id. The motivation: each user + each repo = an individual's coding journey. We need to know the person and the project for every session.

**Two problems to solve:**
1. **Identity** — who is this session from? (user_id / email)
2. **Repo** — what project are they working on? (cwd, repo_url, repo_name, git_branch)

**Complication:** not every CLI source provides cwd.

| Source | Has cwd? | Has git info? | Fallback |
|--------|----------|---------------|----------|
| Claude Code | Yes (every line) | gitBranch | — |
| Codex CLI | Yes (session_meta + turn_context) | branch, commit, repo_url | — |
| Gemini CLI | No | No | `projectHash` (SHA-256, store as-is) |
| Cursor | No | No | Decode slug from file path, validate on disk |

**Identity approach:** No manual onboarding. Identity is discovered automatically from git config:
1. `git -C {cwd} config user.email` — local repo config (most specific)
2. `git config --global user.email` — global config (fallback)
3. `socket.gethostname()` — device name as last resort (no git at all)

Also capture `git config user.name` for display purposes.

**Other decisions:**
- Gemini: store projectHash as-is, no scanning/resolution
- Cursor: decode slug → validate path exists on disk → fallback to slug as-is
- DB: full reset (DROP + recreate), no migrations — not deployed

## Branch

`feat/session-context` off `main`

## GitHub Issues

**Parent Issue:** Add user identity and repo tracking to sessions

**Sub-issues (in order):**

1. Add `SessionContext` to unified schema and update serialization
2. Create `repo_resolver` utility (git resolution, identity resolution, Cursor slug decode)
3. Update DB schema with session context columns (full reset)
4. Populate `SessionContext` in collectors per source
5. Wire daemon startup: pass device_name to collectors

## Step 1: Add `SessionContext` to unified schema

**File:** `qc_trace/schemas/unified.py`

```python
@dataclass
class SessionContext:
    """Per-session context: who is working and on what repo."""
    user_email: str | None = None       # from git config user.email
    user_name: str | None = None        # from git config user.name
    device_name: str | None = None      # socket.gethostname() fallback
    cwd: str | None = None              # working directory
    repo_url: str | None = None         # git remote origin URL
    repo_name: str | None = None        # "org/repo" extracted from URL
    git_branch: str | None = None       # current branch
    git_commit: str | None = None       # HEAD commit hash
    project_hash: str | None = None     # Gemini's opaque projectHash
```

Add to `NormalizedMessage`:
```python
    session_context: SessionContext | None = None
```

**File:** `qc_trace/server/handlers.py`

Update `_dict_to_normalized_message` to parse `session_context` from JSON payload:
```python
def _parse_session_context(data: dict | None) -> SessionContext | None:
    if not data:
        return None
    return SessionContext(**{k: v for k, v in data.items() if k in SessionContext.__dataclass_fields__})
```

No changes needed in `pusher.py` — it uses `dataclasses.asdict()` which handles nested dataclasses.

## Step 2: Create `repo_resolver` utility

**New file:** `qc_trace/utils/repo_resolver.py`

```python
@dataclass
class RepoInfo:
    cwd: str
    repo_url: str | None = None
    repo_name: str | None = None
    git_branch: str | None = None
    git_commit: str | None = None
    user_email: str | None = None    # git config user.email (local)
    user_name: str | None = None     # git config user.name (local)

def resolve_repo(cwd: str) -> RepoInfo:
    """Run git commands against cwd to get repo + identity info."""
    # git remote get-url origin → repo_url
    # git rev-parse --abbrev-ref HEAD → git_branch
    # git rev-parse HEAD → git_commit
    # git config user.email → user_email (local config, most specific)
    # git config user.name → user_name
    # Extract org/repo from URL → repo_name

def resolve_global_identity() -> tuple[str | None, str | None]:
    """Fallback: git config --global user.email/user.name."""

def decode_cursor_slug(slug: str) -> str | None:
    """Decode '-home-sagar-trace' → '/home/sagar/trace'.
    Validate decoded path exists on disk. Return None if not."""

def extract_cursor_slug_from_path(file_path: str) -> str | None:
    """Extract slug from ~/.cursor/projects/{slug}/agent-transcripts/..."""

def extract_gemini_hash_from_path(file_path: str) -> str | None:
    """Extract hash from ~/.gemini/tmp/{hash}/chats/..."""

def _git_cmd(cwd: str, cmd: list[str]) -> str | None:
    """Run a git command, return stdout or None."""

def _extract_repo_name(url: str) -> str:
    """Parse 'org/repo' from SSH or HTTPS git URL."""
```

All stdlib — uses `subprocess.run` with timeout=5, `os.path.isdir` for validation, `re` for URL parsing.

**New file:** `tests/test_repo_resolver.py` — test slug decode, repo name extraction, edge cases.

## Step 3: Update DB schema (full reset)

**File:** `qc_trace/db/schema.sql`

Add columns to `sessions` table:
```sql
CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    source TEXT NOT NULL,
    model TEXT,
    user_email TEXT,                 -- NEW: git config user.email
    user_name TEXT,                  -- NEW: git config user.name
    device_name TEXT,                -- NEW: hostname fallback
    cwd TEXT,                        -- NEW
    repo_url TEXT,                   -- NEW
    repo_name TEXT,                  -- NEW
    git_branch TEXT,                 -- NEW
    git_commit TEXT,                 -- NEW
    project_hash TEXT,               -- NEW
    first_seen TIMESTAMPTZ DEFAULT NOW(),
    last_updated TIMESTAMPTZ DEFAULT NOW(),
    raw_file_path TEXT
);

CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_email);
CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repo_name);
```

Bump version to 2: `INSERT INTO schema_version (version) VALUES (2)`.

Messages table stays unchanged — context is session-level.

**File:** `qc_trace/db/migrations.py` — bump `CURRENT_SCHEMA_VERSION` to 2.

**File:** `qc_trace/db/writer.py` — update `_upsert_sessions` to write new columns from `msg.session_context`:
```python
# INSERT ... (id, source, model, user_id, cwd, repo_url, repo_name,
#             git_branch, git_commit, project_hash, raw_file_path)
# ON CONFLICT (id) DO UPDATE SET
#   last_updated = NOW(),
#   model = COALESCE(EXCLUDED.model, sessions.model),
#   user_id = COALESCE(EXCLUDED.user_id, sessions.user_id),
#   cwd = COALESCE(EXCLUDED.cwd, sessions.cwd),
#   ... (COALESCE pattern for all new fields)
```

**File:** `qc_trace/server/handlers.py` — update `handle_sessions` to accept new fields in POST body.

## Step 4: Populate `SessionContext` in collectors

**File:** `qc_trace/daemon/collector.py`

Update `collect_file` signature:
```python
def collect_file(
    changed: ChangedFile,
    existing_state: FileState | None,
    device_name: str | None = None,
    global_email: str | None = None,
    global_name: str | None = None,
) -> CollectResult:
```

Identity resolution chain (applied in each collector after cwd is known):
1. `resolve_repo(cwd)` → returns `repo_info.user_email` (local git config)
2. If None, fall back to `global_email` (git config --global, passed from daemon)
3. If None, use `device_name` as last-resort identifier

### Claude Code (`_collect_claude`)
- After parsing lines, grab `cwd` and `gitBranch` from first line that has them
- Call `resolve_repo(cwd)` → get repo info + user_email from local git config
- Attach `SessionContext(user_email=..., cwd=cwd, ...)` to all messages

### Codex CLI (`_collect_codex`)
- `CodexTransformContext` already tracks state across lines
- Extend it to capture `cwd` from session_meta payload, and git info if present
- After transforms, call `resolve_repo(ctx.cwd)` if cwd is available
- Prefer raw git info from Codex data over resolved info (it's more accurate)
- Attach `SessionContext` to all messages

### Gemini CLI (`_collect_gemini`)
- Extract `projectHash` from session JSON (`session.get("projectHash")`)
- Also extract from file path as fallback: `extract_gemini_hash_from_path(file_path)`
- No cwd resolution — use `global_email` or `device_name` for identity
- Attach `SessionContext(user_email=global_email, device_name=device_name, project_hash=hash)` to all messages

### Cursor (`_collect_cursor`)
- Extract slug from file path: `extract_cursor_slug_from_path(file_path)`
- Decode slug: `decode_cursor_slug(slug)` → validated cwd or None
- If cwd resolved, call `resolve_repo(cwd)` for repo info + local identity
- Attach `SessionContext(user_email=..., cwd=cwd, ...)` to all messages

**File:** `qc_trace/schemas/codex_cli/transform.py` — extend `CodexTransformContext` to capture `cwd` and git fields from session_meta.

## Step 5: Wire daemon startup

**File:** `qc_trace/daemon/main.py`

In `Daemon.run()`, resolve device name and global git identity once on startup:
```python
import socket
from qc_trace.utils.repo_resolver import resolve_global_identity

self.device_name = socket.gethostname()
self.global_email, self.global_name = resolve_global_identity()
```

Pass `device_name`, `global_email`, `global_name` to `collect_file()` in `_poll_cycle`.
These are fallbacks — the collector prefers per-repo git config when cwd is available.

## Files Modified

| File | Change |
|------|--------|
| `qc_trace/schemas/unified.py` | Add `SessionContext` dataclass + field on `NormalizedMessage` |
| `qc_trace/utils/repo_resolver.py` | **NEW** — git/identity resolution, slug decode, URL parsing |
| `qc_trace/db/schema.sql` | Add 8 columns to sessions (user_email, user_name, device_name, cwd, repo_url, repo_name, git_branch, git_commit, project_hash), new indexes, bump to v2 |
| `qc_trace/db/migrations.py` | Bump `CURRENT_SCHEMA_VERSION` to 2 |
| `qc_trace/db/writer.py` | Update `_upsert_sessions` to write session context columns |
| `qc_trace/daemon/collector.py` | Add identity/device params, populate `SessionContext` per source |
| `qc_trace/daemon/main.py` | Resolve device_name + global git identity on startup, pass to `collect_file()` |
| `qc_trace/server/handlers.py` | Parse `session_context` in deserialization, update `handle_sessions` |
| `qc_trace/schemas/codex_cli/transform.py` | Extend `CodexTransformContext` with cwd/git fields |
| `tests/test_repo_resolver.py` | **NEW** — tests for repo_resolver utility |

## Verification

1. Reset DB: `docker compose down -v && docker compose up -d`, then `qc-traced db init`
2. Start server + daemon
3. Wait for poll cycle → check `curl localhost:7777/api/sessions` → sessions should have `user_email`, `cwd`, `repo_name`, `git_branch`
4. Verify Claude Code sessions have full repo info + user_email from local git config
5. Verify Codex sessions have cwd + git info + user_email
6. Verify Gemini sessions have `project_hash` + user_email (from global git config) + device_name
7. Verify Cursor sessions have decoded cwd + repo info (if path exists on disk) + user_email
8. Run full test suite: `pytest tests/`

## Parallel Execution

The 5 steps have clear dependency boundaries that allow parallel development across two tracks, followed by a sequential integration phase.

### Dependency Analysis

- **Step 1** (unified.py + handlers parse) — no dependencies
- **Step 2** (repo_resolver.py + tests) — no dependencies, completely standalone
- **Step 3** (schema.sql + migrations + writer + handlers session upsert) — `writer.py` imports `NormalizedMessage` and reads `msg.session_context`, so depends on Step 1
- **Step 4** (collector.py + codex transform) — imports `SessionContext` (Step 1) and `resolve_repo` (Step 2)
- **Step 5** (main.py daemon wiring) — imports `resolve_global_identity` (Step 2) and calls updated `collect_file` (Step 4)

### Execution Phases

```
Phase 1 (parallel):   Track A (Steps 1+3)  ||  Track B (Step 2)
Phase 2 (sequential): Track C (Steps 4+5, after A+B merge)
```

### Track A — Schema & Data Pipeline

**Sub-issue:** "Add SessionContext schema and DB pipeline"

**Files:**
- `qc_trace/schemas/unified.py` — Add `SessionContext` dataclass + field on `NormalizedMessage`
- `qc_trace/db/schema.sql` — Add 9 columns to sessions table, new indexes, bump to v2
- `qc_trace/db/migrations.py` — Bump `CURRENT_SCHEMA_VERSION` to 2
- `qc_trace/db/writer.py` — Update `_upsert_sessions` with new columns + COALESCE upsert
- `qc_trace/server/handlers.py` — Add `_parse_session_context`, update `_dict_to_normalized_message` and `handle_sessions`

**Why grouped:** writer.py and handlers.py both depend on `SessionContext` existing in unified.py. These are the "data flows through the system" changes — the schema definition, the DB columns that store it, and the serialization/deserialization on both ends.

### Track B — Repo Resolver Utility

**Sub-issue:** "Create repo_resolver utility"

**Files:**
- `qc_trace/utils/repo_resolver.py` — **NEW** — `RepoInfo`, `resolve_repo`, `resolve_global_identity`, `decode_cursor_slug`, `extract_cursor_slug_from_path`, `extract_gemini_hash_from_path`, `_git_cmd`, `_extract_repo_name`
- `tests/test_repo_resolver.py` — **NEW** — tests for slug decode, repo name extraction, git URL parsing

**Why independent:** repo_resolver.py has its own `RepoInfo` dataclass and only uses stdlib (`subprocess`, `os.path`, `re`). Zero imports from unified.py or any other qc_trace module. Can be developed and tested in complete isolation.

### Track C — Collector Integration + Daemon Wiring

**Sub-issue:** "Wire session context into collectors and daemon"

**Blocked by: Track A + Track B**

**Files:**
- `qc_trace/daemon/collector.py` — Update `collect_file` signature, populate `SessionContext` in all 4 source collectors
- `qc_trace/schemas/codex_cli/transform.py` — Extend `CodexTransformContext` with cwd/git fields
- `qc_trace/daemon/main.py` — Resolve device_name + global identity on startup, pass to `collect_file`

**Why blocked:** collector.py imports `SessionContext` (from Track A) and calls `resolve_repo`/`decode_cursor_slug` (from Track B). This track is the glue that connects the schema to the resolver. Both tracks must be merged before this work can begin.
