Metadata-Version: 2.4
Name: gnosis-air
Version: 0.0.3
Summary: Ingest and analyze AI agent conversation logs (Claude Code, Codex): intent classification, friction analysis, and a cross-session knowledge graph — the gnosis CLI
Author: Roni Dover
Project-URL: Homepage, https://github.com/JetBrains/air-trajectory-analysis
Project-URL: Repository, https://github.com/JetBrains/air-trajectory-analysis
Project-URL: Issues, https://github.com/JetBrains/air-trajectory-analysis/issues
Keywords: claude-code,codex,agent,conversation-analysis,trajectory,llm
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: click>=8.0
Requires-Dist: langchain==1.3.11
Requires-Dist: langchain-core==1.4.8
Requires-Dist: langchain-ollama==1.1.0
Requires-Dist: langchain-anthropic==1.4.8
Requires-Dist: python-dotenv
Requires-Dist: pydantic
Requires-Dist: PyYAML
Requires-Dist: tiktoken
Requires-Dist: numpy
Requires-Dist: sentence_transformers
Requires-Dist: sqlite-vec
Requires-Dist: watchdog>=4.0
Provides-Extra: embark
Requires-Dist: embark-code-methods==0.1.3; extra == "embark"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: black>=23.0; extra == "dev"
Requires-Dist: torch; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Provides-Extra: lint
Requires-Dist: black>=23.0; extra == "lint"
Requires-Dist: mypy>=1.10; extra == "lint"
Requires-Dist: ruff>=0.5; extra == "lint"

# ConversationAdvisor (`gnosis`)

A CLI for ingesting and analyzing AI agent conversation logs (Claude Code, Codex) into a local SQLite store. Tracks every conversation through a three-stage lifecycle — **discovered → imported → analyzed** — so the expensive LLM steps (intent classification, pain-point detection) run once and their results are persisted for later inspection.

## Features

- **Stateful ingestion** — `gnosis discover` walks the default Claude (`~/.claude/projects/`) and Codex (`~/.codex/sessions/`) roots, parses every session file, and stores conversations + messages into a SQLite DB. Incremental: re-running only appends new turns.
- **Intent classification** — `gnosis conversation classify` segments a conversation into discrete tasks by classifying each user message along two axes (Type + Lifecycle). New-Task lifecycle entries become task rows.
- **Pain-point analysis** — `gnosis task analyze` runs a LangGraph DAG (scanner → classifier → resolver) against each task and persists problems with severity, suggested fix, and message-range evidence.
- **Friction-derived effectiveness** — the headline `mean_effectiveness` metric (and the THS effectiveness weight, shown as the **Friction** bar) is computed by `hla.models.friction_score`: the deterministic per-tool effectiveness mean × a confirmed-friction-episode penalty. The deterministic tool scorer is an input to friction, not a separate metric. Per-message `effectiveness` (the tool component) is still stamped on Tool messages. See [docs/effectiveness-metric.md](docs/effectiveness-metric.md) and the friction-analysis section of [CLAUDE.md](CLAUDE.md).
- **Idempotent re-runs** — every stage skips work that's already cached in the store; `--rebuild` flags force re-computation when needed.
- **Two output formats** — most read-only commands accept `--format table|json`. JSON output is unconstrained (no truncation) for machine consumption.
- **Two providers** — Anthropic (claude-haiku) or Ollama (qwen2.5:3b) for local inference.

## Requirements

- Python 3.12+
- One of:
  - An [Anthropic API key](https://console.anthropic.com/)
  - [Ollama](https://ollama.com/) running locally with `qwen2.5:3b` pulled

## Installation

```bash
uv sync                     # primary path; reads uv.lock
uv sync --extra dev         # adds pytest, black, mypy, ruff, torch
uv sync --extra lint        # just black/ruff/mypy (matches the CI lane)
```

`pip install -e .` works too — same `[project]` section in `pyproject.toml`. One caveat: the optional
`embark-code-methods` wheel (tree-sitter structure triage for `gnosis labs review-current-branch`; everything
else works without it) lives on the JetBrains package index, which `uv` resolves automatically via
`[tool.uv.sources]` but plain `pip` does not:

```bash
pip install -e ".[embark]" --extra-index-url https://packages.jetbrains.team/pypi/p/grazi/jetbrains-ai-platform-public/simple
```

To install the published package from PyPI (base feature set, no repo checkout):

```bash
pip install gnosis-air   # or: uv tool install gnosis-air
```

For the Anthropic provider, drop your key in `.env` at the repo root or pass it via `--key`:
```bash
echo 'ANTHROPIC_API_KEY=sk-ant-…' > .env
```

For Ollama:
```bash
ollama pull qwen2.5:3b
ollama serve
```

The SQLite store lives at `~/.local/share/gnosis/gnosis.sqlite3` by default. Override per-invocation via `--db-path` (on `discover`/`stats`) or via the `GNOSIS_DB_PATH` env var (every command).

### Embedding in another application

If you're calling `gnosis` as a subprocess from another application (an IDE plugin, a desktop app, a CI step), the parent doesn't need to assume `uv`, `pip`, or even Python is set up the way the user's interactive shell expects. Use [scripts/bootstrap.py](scripts/bootstrap.py) to install `uv` (if missing) and then install `gnosis` into an isolated `uv tool` venv. Cross-platform — works on Linux, macOS, and Windows.

```python
import subprocess, sys

# One-time bootstrap. Idempotent — safe to call on every launch.
result = subprocess.run(
    [sys.executable, "scripts/bootstrap.py", "--gnosis-ref", "v0.3.0"],
    capture_output=True, text=True, check=True,
)
gnosis_binary = result.stdout.strip().splitlines()[-1]

# Every subsequent invocation: subprocess the binary directly.
subprocess.run(
    [gnosis_binary, "conversation", "messages", "3", "--task", "22", "--format", "json"],
    env={**os.environ, "GNOSIS_DB_PATH": "/path/parent/controls",
         "ANTHROPIC_API_KEY": "sk-ant-…"},
    capture_output=True, check=True,
)
```

The bootstrap script:
- Looks for `uv` on PATH and at astral's standard install locations (`~/.local/bin/uv`, etc.).
- If missing, runs astral's official installer (`curl … | sh` on Unix; `irm … | iex` on Windows) — no third-party hosts.
- Runs `uv tool install --python 3.12 git+<gnosis-url>@<ref>` to create an isolated venv.
- **Verifies** the install by importing gnosis's LLM entry chain in the tool venv, so an incomplete env (e.g. a compiled transitive dep dropped by a partial/proxy-blocked download) fails at bootstrap time with a clear message rather than as a mystery `ModuleNotFoundError` at runtime.
- Mirrors everything — uv's output, exit code, resolved path, verification result — to a timestamped log at `<tempdir>/gnosis/gnosis-bootstrap__<ts>.log`, so a failed install stays inspectable after the subprocess exits.
- Prints the resolved gnosis binary path to stdout's last line; progress goes to stderr.

Pass `--reinstall` (alias `--force`) to force a full rebuild of gnosis **and all its dependencies** — the repair path when a user's env is left incomplete (`uv tool install` is otherwise a no-op if gnosis is already present):

```python
subprocess.run([sys.executable, "scripts/bootstrap.py", "--gnosis-ref", "v0.3.0", "--reinstall"], check=True)
```

The parent should always invoke gnosis with `--format json` (machine-parseable output) and pass `--key` / `--db-path` (or the `ANTHROPIC_API_KEY` / `GNOSIS_DB_PATH` env vars) so the embedded gnosis never touches the user's `.env` or default state path. See `python scripts/bootstrap.py --help` for the available flags.

## CLI Reference

Every command supports `-h` / `--help`. The CLI is a Click group; sub-groups (`conversation`, `task`) have their own subcommands.

### Lifecycle in 30 seconds

```bash
gnosis discover                  # 1. ingest all local sessions into the store
gnosis conversation classify 7          # 2. classify intents + extract tasks for conversation #7
gnosis task analyze 7                 # 3. run pain-point analysis on every task in #7
gnosis task show 7              # 4. browse the results
```

Each step is incremental and persists its output, so step 4 doesn't re-run the LLM. The conversation's `status` field (visible in `gnosis conversation list`) reflects how far it's progressed:

```
Discovered  →  Imported  →  Analyzed
   (1)          (2)           (3)
```

### `discover` — ingest trajectories into the store

```
gnosis discover [--extra-folder PATH]... [--db-path PATH]
                [--rebuild | --rebuild-conversation PATH...]
                [--benchmark] [-v]
```

Scans `~/.claude/projects/` and `~/.codex/sessions/` (plus any `--extra-folder` paths), parses every session, and upserts conversations + messages into the store. Idempotent — re-running only appends new turns.

| Flag | Purpose |
|---|---|
| `--extra-folder PATH` | Additional folder to scan recursively for `*.jsonl`. Repeatable. |
| `--db-path PATH` | Override the default DB location. Beats `$GNOSIS_DB_PATH`. |
| `--rebuild` | Drop every conversation row first, then re-import. FK cascade clears `messages`, `intents`, `tasks`, `problems` too. |
| `--rebuild-conversation PATH` | Scoped rebuild — drop one conversation's row + cascade, then re-ingest it. Repeatable. Mutually exclusive with `--rebuild`. |
| `--benchmark` | Print a stage time / tokens / cost report at the end. (Discover doesn't call any LLMs, so tokens are zero — useful for seeing how long the discovery walk vs ingest pass took.) |
| `-v / --verbose` | Per-call token usage, parser skips, progress bars. |

```bash
gnosis discover                                       # default roots only
gnosis discover --extra-folder ~/old-sessions/        # plus an archive folder
gnosis discover --rebuild                             # wipe + re-import everything
gnosis discover --rebuild-conversation ~/.claude/projects/.../foo.jsonl
gnosis discover --benchmark                           # include the per-stage report
```

### `conversation list` — browse the store

```
gnosis conversation list [--format table|json]
```

Compact table sorted by stable `#` identifier. Columns: `#`, Provider, Title, Path, Project, Status. Read-only — no LLM.

```bash
gnosis conversation list
gnosis conversation list --format json
```

### `conversation show` — full record for one conversation

```
gnosis conversation show <id-or-path> [--format table|json]
```

Identifier is the `#` from `list` (integer) or the absolute path on disk. Prints every stored field plus the tasks summary.

```bash
gnosis conversation show 7
gnosis conversation show /Users/me/.claude/projects/.../uuid.jsonl
gnosis conversation show 7 --format json
```

### `conversation classify` — segment one or more conversations into tasks

```
gnosis conversation classify [IDENTIFIER]
                              [--project NAME] [--folder PATH] [--classify-all]
                              [--benchmark]
                              [-p ollama|anthropic] [--key KEY] [-v]
```

Runs the two-axis intent classifier on every human message in each matched conversation, in chunks of ≤ 4 humans per LLM call. Incremental: humans with a stored intent are skipped. New-Task lifecycle classifications become task rows; the conversation flips to `Imported`.

Exactly one selector must be set:

| Selector | Effect |
|---|---|
| `<IDENTIFIER>` | One conversation, by `#` (digits) or absolute path. |
| `--project NAME` | Every conversation whose `project` column equals NAME. |
| `--folder PATH` | Every conversation whose path starts with `PATH/`. |
| `--classify-all` | Every conversation in the store. |

**Conditional re-classification wipe.** If at least one *new* intent is saved this run, prior analysis state is invalidated: tasks rows replaced (their `analyzed_at`/token columns dropped), all problems deleted, `last_analysis` cleared. On a true no-op re-run (every human already classified), nothing changes — analysis state is preserved.

`--benchmark` prints a per-conversation time / tokens / cost report at the end.

```bash
gnosis conversation classify 7                                       # single by #
gnosis conversation classify /Users/me/.claude/.../uuid.jsonl        # single by path
gnosis conversation classify --project /Users/me/projects/foo        # by project
gnosis conversation classify --folder ~/.claude/projects/            # by folder prefix
gnosis conversation classify --classify-all                          # everything
gnosis conversation classify --classify-all --benchmark
```

### `conversation friction` — multi-tier inefficiency analysis

```
gnosis conversation friction <id-or-path>
                              [--rebuild] [--benchmark] [--format table|json]
                              [-p ollama|anthropic] [--key KEY] [-v]
```

Finds the friction a per-tool-call scan misses — defects the user had to paste back, dissatisfaction loops, blocked stalls, wrong-context requests, output-visibility misses. A deterministic pass (no LLM) surfaces candidate **episodes**; a single batched LLM call then adjudicates each (real? root cause? wasted turns? one-line remedy). ~$0.02 per conversation on Haiku.

Results persist to the store. Re-running renders the cached verdicts without an LLM call; pass `--rebuild` to re-detect and re-adjudicate. `--format json` emits the full payload (episodes + verdicts + a wasted-turn rollup).

```bash
gnosis conversation friction 2                 # detect + adjudicate (or render cached)
gnosis conversation friction 2 --benchmark     # + time / tokens / cost
gnosis conversation friction 2 --format json   # structured payload
gnosis conversation friction 2 --rebuild       # force re-analysis
```

### `task list` — browse one conversation's tasks

```
gnosis task list <id-or-path> [--format table|json]
```

Compact table: `#`, Status (`not analyzed` / `analyzed`), Type, Position, Title. Read-only.

```bash
gnosis task list 7
gnosis task list 7 --format json
```

### `task show` — detailed task view

```
gnosis task show <id-or-path> [--task N] [--format table|json]
```

Without `--task`: every task is printed in full detail. With `--task N`: just that one (errors if out of range).

Each task includes: intent segments (text + Type + Lifecycle), message-range `start → end`, `start_time` / `end_time` from the source JSONL, per-type step counts (`user`, `agent`, `tool`), `analyzed_at` and tokens spent (when analyzed), and any persisted problems with their severity / fix / analysis. Read-only.

```bash
gnosis task show 7
gnosis task show 7 --task 2
gnosis task show 7 --format json
```

### `task analyze` — find pain points in a conversation

```
gnosis task analyze <id-or-path> [--task N] [--rebuild] [--benchmark]
                    [-p ollama|anthropic] [--key KEY] [-v]
```

Runs the LangGraph pain-point pipeline against each task in a conversation. Per-task idempotent — tasks already analyzed (`analyzed_at` stamped) are rendered from the store without re-running the LLM. With `--task N` it scopes to one task.

**`--rebuild`** clears problems + `analyzed_at` for the scope (whole conversation or single task) up front, then re-analyzes everything. Use after fixing prompt/scoring code or when you want a fresh read.

**`--benchmark`** prints a per-task time / tokens / cost report at the end.

On success: problems persisted, `tasks.analyzed_at` / `input_tokens` / `output_tokens` stamped per task, `conversations.last_analysis` updated → status flips to `Analyzed`.

```bash
gnosis task analyze 7                                   # analyze unanalyzed tasks in #7
gnosis task analyze 7 --task 2                          # only task #2 (cached if already done)
gnosis task analyze 7 --rebuild                         # force re-analysis of every task
gnosis task analyze 7 --task 2 --rebuild                # force re-analysis of just task #2
gnosis task analyze 7 --benchmark                       # add the per-stage report
```

### `stats` — store summary

```
gnosis stats [--db-path PATH]
```

Prints: total conversations, distinct projects, median human/agent/tool messages per conversation.

```bash
gnosis stats
GNOSIS_DB_PATH=/tmp/scratch.sqlite3 gnosis stats
```

### `score` and `intents` (legacy, file-based)

These two haven't been migrated to the store. They take a session file path (or directory) directly.

```bash
gnosis score   <session.jsonl> [-p anthropic|ollama] [--key KEY] [-v]
gnosis intents <session.jsonl> [-p anthropic|ollama] [--key KEY] [-v]
```

`score` returns one of `Bad` / `Ok` / `Good` for the whole conversation. `intents` prints the full per-message intent table.

---

## Architecture

### High-level data flow

The pipeline is **session file → parsed messages → SQLite store → on-demand LLM stages**. The store is the single source of truth for everything except the freshly-parsed `BaseMessage` list during `discover`.

```mermaid
flowchart TB
    classDef store fill:#dde,stroke:#666
    classDef agent fill:#fdd,stroke:#666

    subgraph Files["~/.claude/projects/, ~/.codex/sessions/, --extra-folder"]
        SF[".jsonl files"]
    end

    SF --> Disc

    subgraph Layers["src/"]
        direction TB
        Disc["discovery/<br/>find trajectory files<br/>per provider"]
        Pars["parsers/<br/>JSONL → list[BaseMessage]<br/>(Claude / Codex)"]
        HLA["hla/<br/>conversation_outline<br/>(skeleton, chunks, IDE-strip)"]
        Agents["agents/<br/>intent_analyzer<br/>conversation_optimizer<br/>user_satisfaction"]
        Comp["analyzers/<br/>comprehensive_scan/<br/>(LangGraph DAG)"]
        Cmds["commands/<br/>one file per CLI verb"]
        Store[("storage/<br/>SQLite store<br/>~/.local/share/gnosis/<br/>gnosis.sqlite3")]
    end

    Disc --> Pars
    Pars --> HLA
    HLA --> Agents
    Agents --> Comp
    Cmds <--> Store
    Cmds --> Agents
    Pars --> Store
    Store:::store
    Agents:::agent
    Comp:::agent

    Store -. read .-> CLI["gnosis CLI<br/>(Click)"]
    CLI --> Cmds
```

**Layer rules** (load-bearing — see [CLAUDE.md](CLAUDE.md) for the full list):
- `commands/` is the only layer the CLI talks to.
- `agents/` is where every LLM call lives. Never imports from `parsers/` or `discovery/` — agents work on already-parsed `BaseMessage` lists.
- `hla/` (no-LLM data shaping) and `agents/` (LLM) are siblings.
- `storage/` is provider-agnostic; everything in `commands/`, `agents/`, and `hla/` reads/writes through its helpers.

### SQLite schema

Five tables, all in SQLite `STRICT` mode. The lifecycle is encoded in the FK cascades.

```mermaid
erDiagram
    conversations ||--o{ messages : "cascades on delete"
    messages ||--o| intents : "(path, position) cascades"
    intents ||--o| tasks : "(path, position) cascades"
    conversations ||--o{ problems : "cascades on delete"

    conversations {
        INTEGER id "stable # identifier"
        TEXT path PK "absolute trajectory path"
        TEXT provider "claude | codex"
        TEXT title
        TEXT project
        TEXT created_at
        TEXT last_updated_at
        TEXT first_ingested_at
        TEXT last_ingested_at
        TEXT last_task_analysis "stamped by conversations classify"
        TEXT last_analysis "stamped by analyze"
    }
    messages {
        TEXT conversation_path PK,FK
        INTEGER position PK "0-based parser ordinal"
        TEXT type "human | agent | tool"
        TEXT uuid "stable for claude"
        TEXT timestamp "from raw JSONL entry"
        TEXT payload "pydantic model_dump_json"
    }
    intents {
        TEXT conversation_path PK,FK
        INTEGER position PK,FK "→ messages.position"
        TEXT title
        TEXT segments_json "JSON list[{text, type, lifecycle}]"
        TEXT classified_at
    }
    tasks {
        TEXT conversation_path PK,FK
        INTEGER number PK "1-indexed per conversation"
        INTEGER position UK,FK "→ intents.position"
        TEXT type
        TEXT title
        TEXT analyzed_at "NULL = classified, NOT NULL = analyzed"
        INTEGER input_tokens
        INTEGER output_tokens
    }
    problems {
        TEXT conversation_path FK
        INTEGER task_number "FK by convention; NULL for fallback runs"
        TEXT type
        INTEGER relevant_message_range_start
        INTEGER relevant_message_range_end
        REAL severity
        TEXT description
        TEXT suggested_fix
        TEXT analysis
        TEXT created_at
    }
```

The cascade chain: deleting a conversation row → cascades to its `messages` and `problems` rows → which cascades to `intents` → which cascades to `tasks`. This is what makes `discover --rebuild` a clean wipe and what keeps `--rebuild-conversation <path>` precisely scoped.

### Per-conversation status derivation

Computed in [storage/conversations.py:compute_status](src/storage/conversations.py) from two signals:

```mermaid
stateDiagram-v2
    [*] --> Discovered : gnosis discover
    Discovered --> Imported : gnosis conversation classify
    Imported --> Analyzed : gnosis task analyze
    Analyzed --> Imported : conversation classify (invalidates analysis)
    Imported --> Imported : conversation classify (incremental)
    Analyzed --> Analyzed : analyze (cached re-render)
    Analyzed --> Analyzed : analyze --rebuild
```

- `Discovered` — `tasks` table is empty for this conversation.
- `Imported` — at least one task row, `last_analysis IS NULL`.
- `Analyzed` — `last_analysis IS NOT NULL`.

Per-task status is independent and shown in `task list` / `task show`:
- `not analyzed` — task row exists, `analyzed_at IS NULL`.
- `analyzed` — `analyzed_at IS NOT NULL` (problems persisted, tokens recorded).

### `discover` flow

```mermaid
flowchart TD
    cli["gnosis discover [--extra-folder PATH]... [--rebuild | --rebuild-conversation PATH]..."]
    cli --> resolveDB["resolve DB path<br/>(--db-path > $GNOSIS_DB_PATH > default)"]
    resolveDB --> openDB["open + migrate schema<br/>(CREATE TABLE IF NOT EXISTS + idempotent ALTER TABLE)"]
    openDB --> rebuild{--rebuild?}
    rebuild -- yes --> wipeAll["DELETE FROM conversations<br/>(cascades to all tables)"]
    rebuild -- no --> rebuildOne{--rebuild-conversation?}
    rebuildOne -- yes --> wipeOne["DELETE FROM conversations<br/>WHERE path IN (...) (cascades)"]
    rebuildOne -- no --> walk
    wipeAll --> walk
    wipeOne --> walk
    walk["discover_trajectories(project=None)<br/>+ glob each --extra-folder"]
    walk --> perTraj[for each trajectory]
    perTraj --> parse["read + parsers.get_messages(content)<br/>list[BaseMessage] with timestamps"]
    parse --> fill["hla.fill_tool_success(msgs[existing_count:])<br/>(sentence-transformer; cached centroid)"]
    fill --> upsert["upsert conversation + append messages<br/>(BEGIN IMMEDIATE; idempotent)"]
    upsert --> uuidCheck{first N stored uuids<br/>match parsed prefix?<br/>(claude only)}
    uuidCheck -- yes --> next[next trajectory]
    uuidCheck -- no --> skip["skip with warning<br/>(tree branch divergence)"]
    skip --> next
    next --> done(["summary line:<br/>N scanned, M new conversations,<br/>K new messages"])
```

### `conversation classify` flow

```mermaid
flowchart TD
    cli["gnosis conversation classify (one selector required)"]
    cli --> resolve["find_conversation_by_identifier<br/>(digits → id, else path)"]
    resolve --> miss{found?}
    miss -- no --> err1["Error → stderr"]
    miss -- yes --> load["load_messages + load_intents"]
    load --> diff["new_positions = {all humans} − {classified}"]
    diff --> any{new_positions empty?}
    any -- yes --> stamp
    any -- no --> chunk["create_messages_skeleton_outline<br/>chunk_messages(max_humans=4)"]
    chunk --> filter["keep chunks containing ≥1 new human"]
    filter --> llm["per chunk:<br/>analyze_intents_from_outline<br/>(retry once on missing indexes)"]
    llm --> persist["save_intents (INSERT OR IGNORE)<br/>only new positions"]
    persist --> stamp
    stamp["compute task boundaries<br/>save_tasks (DELETE + INSERT)<br/>set_last_task_analysis(now)<br/>delete_problems(path)<br/>UPDATE last_analysis = NULL"]
    stamp --> summary["summary:<br/>N tasks identified<br/>+ note if prior analysis was cleared"]
```

### `task analyze` flow

```mermaid
flowchart TD
    cli["gnosis task analyze &lt;id-or-path&gt; [--task N] [--rebuild] [--benchmark]"]
    cli --> guard["check: conversation exists,<br/>last_task_analysis IS NOT NULL"]
    guard --> scope{--task N?}
    scope -- yes --> single["scope = [task N]"]
    scope -- no --> all["scope = every task"]
    single --> rebuildQ
    all --> rebuildQ
    rebuildQ{--rebuild?}
    rebuildQ -- yes --> wipe["upfront wipe in scope:<br/>delete_problems(...) +<br/>UPDATE tasks SET analyzed_at=NULL"]
    rebuildQ -- no --> part["partition scope<br/>by analyzed_at IS NULL/NOT NULL"]
    wipe --> part2["to_analyze = scope<br/>cached = []"]
    part --> loop
    part2 --> loop
    loop[for each task in to_analyze]
    loop --> benchmark["with benchmark(task #N) as section:<br/>find_problems(msgs[start:end])<br/>(comprehensive_scan LangGraph:<br/>Scanner → Classifier×N → Resolver)"]
    benchmark --> save["BEGIN IMMEDIATE:<br/>delete_problems(path, task=N)<br/>save_problems(...)<br/>mark_task_analyzed(N, tokens, now)"]
    save --> loop
    loop -- done --> render["load_problems(path)<br/>reconstruct full TaskAnalysis from cached + new<br/>+ stored task ranges<br/>compute IQC / per-task TQC<br/>print report + benchmark"]
```

`find_problems` invokes the LangGraph DAG in [analyzers/comprehensive_scan/](src/analyzers/comprehensive_scan/) — a pre-scanner node first stamps anchor-driven `effectiveness` scores on each Tool / Agent message (see [docs/effectiveness-metric.md](docs/effectiveness-metric.md)); a cost-scanner stamps `cost_effectiveness` on each Human; then scanner finds suspect ranges, classifier verifies each via a sub-agent with `get_messages_by_range` / `get_detailed_message` tools, and resolver assigns severity + fix. See [CLAUDE.md](CLAUDE.md) for the full walkthrough.

## Project structure

```
.
├── src/
│   ├── gnosis.py                          # Click CLI entry point
│   ├── main.py                            # legacy argparse entry (discover/show)
│   ├── llm.py                             # provider abstraction + llm_invoke
│   ├── utils.py
│   ├── tool_output_classifier.py          # sentence-transformer for tool success/failure
│   ├── pattern_scanner.py                 # heuristic helpers (unused; pending cleanup)
│   ├── commands/                          # one file per CLI verb
│   │   ├── analyze.py, conversations.py,
│   │   │ discover.py, intents.py, score.py,
│   │   │ show.py, stats.py, tasks.py
│   │   └── runner.py                      # iter_session_files + run_over_path
│   ├── parsers/                           # JSONL → list[BaseMessage]
│   │   ├── base.py, claude.py, codex.py, messages.py
│   ├── discovery/                         # find session files per provider
│   ├── storage/                           # SQLite store
│   │   ├── db.py                          # connection, schema bootstrap, migrations
│   │   └── conversations.py               # CRUD + StoredTask/StoredIntent/StoredProblem
│   ├── hla/                               # non-LLM data shaping
│   │   ├── conversation_outline.py        # outline, skeleton, chunking, ide-block strip
│   │   ├── benchmark.py                   # BenchmarkSection + UsageTracker
│   │   └── models.py                      # Problem, TaskAnalysis, ByTaskAnalysisResult
│   ├── agents/                            # LLM-driven analyzers
│   │   ├── intent_analyzer.py             # two-axis classification
│   │   ├── conversation_optimizer.py      # find_problems (entry to comprehensive_scan)
│   │   └── user_satisfaction.py
│   ├── analyzers/comprehensive_scan/      # LangGraph DAG: scanner → classifier → resolver
│   ├── analysis_tools/                    # LangChain tools used by sub-agents
│   └── contracts/                         # shared types (IntentType, TaskBoundary, etc.)
├── data/sessions/                         # bundled sample conversations
├── experiments/                           # exploratory notebooks (not tests)
└── .github/workflows/lint.yml             # black + ruff + mypy on every PR
```

## LLM providers

`LLMConfig` in [src/llm.py](src/llm.py) carries provider, model, and per-million-token pricing. Two presets:

| Preset | Provider | Model |
|---|---|---|
| `LLMConfig.claude_haiku_4_5_20251001()` | Anthropic | claude-haiku-4-5-20251001 |
| `LLMConfig.ollama_qwen_2_5__3b()` | Ollama | qwen2.5:3b |

`llm_invoke()` is the single entry point — it builds the langchain client, runs structured-output inference, wires the `UsageTracker` callback so per-task tokens flow into the active `BenchmarkSection`, and prints per-call cost.
