# Codevira v2.1.2 — Trust Recovery + QoL

## Context

v2.1.1 shipped hybrid `search_decisions` (BM25 + ChromaDB semantic + RRF) to
close the UDAP-benchmark gap where natural-language queries returned 0 hits.
**That fix shipped without a similarity floor**, so three queries from the
third field-test report now return 5 results unconditionally:

| Query                                 | v2.1.1 result    |
|---------------------------------------|------------------|
| `"zzzzzz nonexistent gibberish xqzv9"`| 5 results        |
| `""` (empty string)                    | 5 results (recent)|
| `"how to make a cake"`                 | 5 results (locked decisions about LLMProvider, MIME, ReDoS) |

This poisons the killer feature (UserPromptSubmit hook injection): off-topic
prompts now surface irrelevant decisions, training users to ignore the hook.
**Three independent field-test reports** name "trust" as the gap, not
capability. v2.1.2 is a TRUST-RECOVERY release: every fix restores
confidence in something codevira already does.

Scope: **33 items in v2.1.2** — 4 trust-recovery + 4 QoL wins +
1 export command + 6 audit-recovered + README rewrite + plan-in-codebase
+ 3 from Report 4 (rule-extractor noise, phase placeholder pollution,
file_path serialization quirk) + 14 promoted from the deferred backlog
per user instruction "why cannot we take deferred item in the current
plan as everyone has reported the same." Single release batch, ship
within ~6-7 days (was 3-4 — expanded scope adds ~25 hours).

**Plan audit notes:**
- First draft missed 6 real items raised across the three field-test
  reports (now items 9–14).
- Second-pass user feedback added items 15 (README rewrite for user
  attraction) and 16 (track plan in codebase) plus a cross-cutting
  documentation discipline rule.
- Third-pass (Report 4 — 2026-05-18 memory-dump session): added items
  17–19 (trust + serialization).
- Fourth-pass (user instruction to fold all deferred items in because
  multiple reports converged): added items 20–33. The previously-empty
  v2.1.3 deferral list is now genuinely the v2.2 list — every backlogged
  feedback item is in this release.
- `v2.1.3` is now reserved for whatever shakes out post-launch; no items
  pre-allocated.

**Cross-cutting rule (per user instruction):** every code change in this
release MUST update the relevant user-facing documentation in the same
commit. See "Documentation discipline" section.

---

## Item 1 — Smart similarity threshold for `search_decisions`

**Problem.** `semantic_search_decisions` in `mcp_server/tools/_decision_embeddings.py`
calls `collection.query()` and returns top-K IDs without inspecting cosine
distance. Gibberish queries → "least bad" matches surface as if relevant.

**Design — smart, self-calibrating (not fixed):**

| Layer | Default | Adaptive |
|---|---|---|
| Static fallback | search: 0.45 / hook inject: 0.30 | Used until enough data |
| Per-project calibration | re-fits every N decisions added (default N=10) | Stored in `<data_dir>/calibration.json` |
| On-demand recalibration | `codevira calibrate --decisions` | Manual re-fit |

**Calibration algorithm** (simple, no ML):
1. Pull all "positive samples": decisions marked `do_not_revert=True` OR
   confirmed kept via outcome tracker (= the decisions the user clearly
   valued).
2. For each positive sample, embed its text and find its 10 nearest neighbors
   in the project's decision corpus (via ChromaDB).
3. Record the cosine distances of those 10-nearest results (these are "should
   surface" pairs).
4. Set the threshold at the **75th percentile** of that distribution — i.e.,
   "be at least as similar as 75% of historical surface-worthy pairs."
5. Cap to safe bounds: `0.20 <= threshold <= 0.55` (never absurdly lax nor
   absurdly strict).
6. Hook-injection threshold = `search threshold - 0.10` (always stricter).

**Fallback**: if positive samples < 5, use static defaults (0.45 / 0.30).
Stored at `<data_dir>/calibration.json` with timestamp + sample-count metadata.

**Files touched:**
- `mcp_server/tools/_decision_embeddings.py` — extend
  `semantic_search_decisions` to return `[(id, distance), ...]`; add
  `_load_threshold()` + `_recalibrate_threshold()` helpers.
- `mcp_server/tools/search.py::search_decisions` — apply threshold filtering
  after RRF merge; expose `score` field per result; expose `threshold_used`
  in response payload.
- `mcp_server/engine/policies/cross_session.py::_collect_matches` (lines
  152–182) — apply STRICTER threshold (delta=-0.10) for hook injection.
- NEW: `mcp_server/cli_calibrate.py` — `cmd_calibrate(target='decisions')`.
- `mcp_server/cli.py` — register `calibrate` subparser + dispatch.
- Auto-recalibration trigger: in `mcp_server/tools/learning.py::record_decision`,
  after the embed, if `(decisions_count % N) == 0`, call recalibration in a
  background thread (non-blocking).

**RRF**: stay rank-based (current code). Filtering happens BEFORE RRF, at the
`semantic_search_decisions` boundary. This way the threshold is one source of
truth, RRF stays simple.

---

## Item 2 — `get_node` / `get_impact` / `query_graph` return `null` + `not_indexed: true`

**Problem.** Tools currently return `dependencies_count: 0, blast_radius: 0`
for files codevira hasn't indexed yet (Phase-4 new files in Report 3, all
markdown in Report 2). Agents trust the false zero and act unsafely.

**Design:**

For the existing **3-case differentiation** in `mcp_server/tools/graph.py`
(no graph DB / empty graph / file not in graph), add a fourth distinguishing
field `not_indexed: True` to the third case. Counts become `null` instead of
absent (so JSON consumers know to display "—" not "0").

```python
# Before (file not in graph case)
return {"found": False, "file_path": file_path, "message": "...",
        "fix_command": "codevira index", "hint": "..."}

# After
return {"found": False, "file_path": file_path, "not_indexed": True,
        "dependencies_count": None, "rules_count": None,
        "key_functions_count": None, "blast_radius": None,
        "message": "...", "fix_command": "codevira index", "hint": "..."}
```

Backward-compat note: agents reading `dependencies_count` will now see
`null` not `0` for unindexed files. Existing tests that assert `== 0` for
this case need updating to `is None`.

**Files touched:**
- `mcp_server/tools/graph.py::get_node` (lines 273–288) — add `not_indexed`
  branch
- `mcp_server/tools/graph.py::get_impact` (lines 363–418) — same
- `mcp_server/tools/graph.py::query_graph` (lines 699–718, 720–787) — same
- `mcp_server/tools/graph.py::update_node` — Report 4 #10: the "Node not
  found" error in a pre-code project should explicitly say WHY ("graph
  empty: this project hasn't been indexed yet — run `codevira index`")
  instead of leaving the agent guessing. Same `not_indexed: True` flag
  on the error payload.
- Tests: `tests/test_tools_graph.py` (if exists) + add new test that asserts
  `not_indexed: True` + counts are None for unindexed paths

---

## Item 3 — Heal safety: auto-export + reset alias + deprecation

**Problem.** `heal --graph` and `heal --all` `rm -rf graph/` which contains
the **decisions, outcomes, preferences, learned_rules, sessions** tables.
Silent data loss. `heal` connotes "fix in place" — it actually means "wipe
and rebuild."

**Three sub-items combined:**

### 3a. Auto-export decisions before destructive ops (ON by default)

Before any wipe of `graph/` (in `--graph` and `--all` modes), automatically:
1. Read all rows from `decisions`, `outcomes`, `preferences`, `learned_rules`,
   `sessions` tables via SQLiteGraph's existing methods.
2. Write to `<data_dir>/exports/<timestamp>.json` (structured JSON,
   human-readable).
3. Also write `<data_dir>/exports/<timestamp>.sql` (via
   `db.conn.iterdump()` — replay to fully reconstruct).
4. Print path + size to user before confirmation prompt.

Flag: `--no-backup` (default False) to skip.

### 3b. New `codevira reset` command — destructive operations move here

```bash
codevira reset --vectors   # wipe codeindex/ (was: heal --vectors)
codevira reset --graph     # wipe graph/    (was: heal --graph)
codevira reset --all       # wipe both + sessions (was: heal --all)
codevira reset --no-backup # skip auto-export (use with caution)
codevira reset --dry-run
codevira reset --yes
```

`codevira heal` becomes non-destructive ONLY:
```bash
codevira heal --decisions  # backfill decision embeddings (existing v2.1.1, unchanged)
codevira heal              # (FUTURE v2.2) diagnose + auto-repair where safe
```

### 3c. Deprecate `heal --vectors / --graph / --all`

For v2.1.x: old flags still work but print a deprecation warning:

```
⚠  `codevira heal --vectors` is deprecated and will be removed in v2.2.
   Use `codevira reset --vectors` instead. This command IS destructive —
   reset is the honest name.
```

### 3d. Confirmation prompt upgrade

Before reset proceeds, the confirmation must SCREAM about decisions:

```
⚠  DESTRUCTIVE OPERATION
   --graph wipes ~/.codevira/projects/<key>/graph/ which contains:
     • 23 decisions
     • 5 outcomes
     • 1 learned rule
     • 12 session logs
   Auto-backup: ON (~/.codevira/projects/<key>/exports/2026-05-18T...)
   Type 'reset' (literal) to proceed:
```

User must type the word `reset` (or `vectors` / `all` for those flags), not
just `y`. Higher friction = harder to accidentally trash decisions.
`--yes` flag still skips the typed confirmation (for scripts).

**Files touched:**
- NEW `mcp_server/cli_export.py` — `cmd_export(target, format, out)` +
  shared `_export_decisions_to_path(data_dir, path)` helper used by BOTH
  the standalone command AND the auto-backup before reset.
- NEW: `cmd_reset` in `mcp_server/cli.py` (refactor of current cmd_heal
  destructive paths) + new `reset_parser` subparser.
- MODIFY `mcp_server/cli.py::cmd_heal` — keep only `--decisions` flag; if
  invoked with deprecated `--vectors/--graph/--all`, print deprecation
  warning + forward to `cmd_reset`.
- MODIFY `mcp_server/_prompts.py` — add `confirm_typed(question, expected,
  default)` helper that requires typing the exact word.
- Tests: new `tests/test_cli_reset.py` covering backup-on-by-default,
  --no-backup opt-out, typed confirmation, deprecation warning on old heal.

### 3e. Standalone `codevira export decisions` command

Since we're building the export helper for #3a, expose it standalone:

```bash
codevira export decisions [--format json|sql] [--out PATH]
```

Closes Report 1 §7's "Is there an export tool (`codevira export decisions
> backup.json`) we're missing?" gap. ~30 minutes of CLI plumbing on top of
the helper.

---

## Item 4 — Auto-refresh stale graph nodes post-edit

**Problem.** Watcher reindexes Chroma chunks on file change but doesn't
refresh graph nodes. Agent edits a file → `get_node` keeps returning
`stale: true` + old dependency count forever, until manual
`codevira index --full`.

**Design:**

Add a PostToolUse engine hook that, after a successful Edit/Write/MultiEdit
tool call, schedules a lightweight per-file graph refresh:

1. Extract `file_path` from the tool_input.
2. Call `db.update_node_metadata` via `update_node` if node exists, OR
   trigger a single-file reparse via `chunk_file(path, project_root)` +
   `add_node(...)` if not.
3. Re-walk imports (existing `extract_imports` in `indexer/chunker.py`) +
   update `dependencies` JSON column.
4. Touch `.last_indexed` file so `_check_staleness` returns fresh.

Run in a background thread so PostToolUse doesn't block the AI tool
response. Failures logged, never block.

**Files touched:**
- NEW `mcp_server/engine/policies/post_edit_refresh.py` — new policy
  registered for `POST_TOOL_USE` event, matches Edit/Write/MultiEdit
  tools.
- `mcp_server/engine/__init__.py::register_default_policies` — register
  the new policy.
- `indexer/index_codebase.py` — add a `refresh_single_node(file_path)`
  helper (or expose existing logic from `chunk_project`'s per-file loop).
- Tests: `tests/test_post_edit_refresh.py` covering successful refresh +
  graceful failure on missing file.

---

## Items 5–8 — QoL wins (small, included in v2.1.2)

### 5. `do_not_revert: 1` → `true` type consistency

`record_decision`, `search_decisions`, `mark_decision_protected` responses
all return `do_not_revert: 1` (SQLite integer). API contract says bool.

**Files touched:**
- `indexer/sqlite_graph.py::record_decision`,
  `set_decision_protection`, `search_decisions` — coerce
  `do_not_revert` to `bool` before returning.
- `mcp_server/tools/learning.py`, `mcp_server/tools/search.py` — defensive
  coerce in case any path leaks the int.
- Tests: update assertions in `tests/test_tools_learning.py` etc.

### 6. Stop mid-word truncation in `top_signals.rules`

Currently truncates rule text at 100 chars mid-path:
`"test/uni…"`. Fix: truncate at word boundary, append `…`. If the rule
contains a path, prefer truncating only the description part and keeping
full paths.

**Files touched:**
- `mcp_server/engine/signals.py` (or wherever `top_signals` is composed) —
  add `_smart_truncate(text, limit)` that respects path separators + word
  boundaries. Bump limit to 160 chars while we're there.

### 7. Replace `summary: "ad-hoc record_decision"` placeholder

Currently every ad-hoc decision row shows `summary: "ad-hoc
record_decision"` in subsequent searches. Derive summary from first 80
chars of decision text instead.

**Files touched:**
- `indexer/sqlite_graph.py::record_decision` (lines 802–841) — when
  creating the auto-generated session row, set
  `summary = decision[:80].rstrip() + "…"` if `len > 80`, else
  `summary = decision`.

### 8. Hide empty auto-signal fields on fresh projects

`get_session_context` returns `confidence: null,
top_signals.preferences: []` even after recording decisions, because those
fields require accumulated git outcome history. For new users, this looks
broken.

**Files touched:**
- `mcp_server/tools/learning.py::get_session_context` — when
  `confidence.total_decisions == 0` AND `decisions_in_db_total > 0`,
  replace the `confidence` block with a single
  `confidence_note: "Outcome tracker has not classified any decisions yet
  — accumulates over git commits."`.
- Same pattern for empty `top_signals.preferences` / `learned_rules`:
  omit the field OR include a `note` field explaining why empty.

---

## Item 9 — Cross-project rules leak audit + fix (correctness bug)

**Problem (Report 1 §3.3 + §7).** `top_signals.rules` returned learned rules
from an **unrelated Go project** (`operator/cmd/uadp/`) on the same machine.
Tester called this "the most concerning finding" — could cause an agent to
apply patterns from the wrong project. The first field-test report
explicitly asked: *"Is project scoping for `top_signals.rules` configurable,
or always machine-global?"*

**Plan:**

1. **Audit** (~30 min): trace `top_signals.rules` from `get_session_context`
   down to its query. Find where `learned_rules` are pulled — is the query
   filtered by current project_id, or does it pull machine-global?
2. **If global**: that's the bug. Fix the query to scope to the current
   project's `data_dir` (which is per-project already). The
   `learned_rules` table lives in `graph.db` which IS per-project — so
   the leak is likely from `global_sync.py` exporting/importing across
   projects via `~/.codevira/global.db`.
3. **Add a test** that records a rule in one project's graph.db, then
   verifies `get_session_context` from a SECOND project does NOT surface it.
4. **Document**: README + CHANGELOG note that learned rules are
   per-project, global preferences cross projects (if that's the design).

**Files to investigate:**
- `mcp_server/global_sync.py`
- `mcp_server/tools/learning.py::get_session_context` and `get_top_signals`
- `indexer/sqlite_graph.py::record_preference` / `record_outcome` (they
  may write to global.db)

**Severity**: HIGH (correctness, cross-project pollution can cause an
agent to apply wrong-project patterns). Must ship in v2.1.2.

---

## Item 10 — `complete_phase --backfill` for retroactive phase completion

**Problem (Report 2).** `complete_phase` only completes the CURRENT phase.
Adopters with prior git history (Report 2 had 4 phases shipped in git, 0
in codevira) can't backfill. They worked around by completing a generic
placeholder Phase 1 with `key_decisions` documenting historical work —
"felt like a hack."

**Plan:**

Add `--backfill` flag to `complete_phase` that accepts any phase number
(not just current) and an explicit `completed_at` ISO timestamp:

```bash
mcp__codevira__complete_phase(phase_number=2, key_decisions=[...],
                              backfill=True, completed_at="2026-04-15")
```

Behaviors:
- If `backfill=True` AND `phase` is not current: don't advance queue, just
  mark the phase row as `status="done"` with `completed_at=<ts>`.
- If `backfill=True` AND `phase` IS current: same as current behavior
  (advance queue).
- Default `backfill=False` preserves current strict behavior.

**Files touched:**
- `indexer/sqlite_graph.py` — find the `complete_phase` SQLite logic; add
  `backfill` parameter.
- `mcp_server/tools/roadmap.py` (or wherever `complete_phase` MCP tool
  lives) — thread the param through.
- Tests: assert backfill mode marks the right phase without advancing
  current.

---

## Item 11 — `list_decisions(...)` enumeration command

**Problem (Report 3 §"What's genuinely bad" #3).** No way to enumerate all
decisions. Tester: *"For a system whose value comes from 'remember things
across sessions,' not being able to enumerate is a real gap. A
`list_decisions(limit, since_date, file_pattern)` would be 30 minutes of
work and would close this."*

This is also #3 in Report 3's "founder's next sprint" — explicit priority.

**Plan:**

Add `list_decisions` MCP tool:

```python
list_decisions(
    limit: int = 20,           # cap
    since_date: str = None,    # ISO 8601 — decisions after this date
    file_pattern: str = None,  # SQL LIKE pattern on file_path
    protected_only: bool = False,
    session_id: str = None,
    full: bool = False,        # full vs truncated
) -> {"count": N, "decisions": [...], "has_more": bool}
```

Plus CLI version: `codevira list-decisions --limit 50 --protected-only`.

**Files touched:**
- NEW `mcp_server/tools/list_decisions.py` (or extend
  `mcp_server/tools/search.py`)
- `mcp_server/server.py` — register new MCP tool
- `mcp_server/cli.py` — add `list-decisions` subparser
- Tests covering each filter combination

---

## Item 12 — `complete_phase` accepts optional `git_ref` linkage

**Problem (Report 2 §"Roadmap doesn't auto-link to git").** Merge commits
and PR numbers must live as freeform strings inside decision text or
session-log task descriptions. No structural link between codevira phases
and git history.

**Plan:**

Add optional `git_ref` parameter to `complete_phase` (and `write_session_log`):

```python
complete_phase(phase=4, key_decisions=[...], git_ref="abc123def")
# or
complete_phase(phase=4, key_decisions=[...], git_ref="PR #42")
```

Stored as a column on the `phases` table. Surfaced in `get_phase` and
`get_roadmap` responses. Optional — backwards compatible.

If `git_ref` looks like a sha (`^[a-f0-9]{7,40}$`), `get_phase` also
returns a `git_show_command: "git show abc123def"` hint so agents can
fetch the commit content.

**Files touched:**
- `indexer/sqlite_graph.py` — schema migration: add `git_ref TEXT`
  column to `phases` table (use `ALTER TABLE` with IF NOT EXISTS pattern,
  or rebuild table — depends on existing patterns).
- `mcp_server/tools/roadmap.py` — accept + persist the field.
- Tests: round-trip git_ref through complete_phase → get_phase.

---

## Item 13 — `clean --orphans` handles bare global.db rows

**Problem (yesterday's cleanup session, not in any field report but
discovered hands-on).** `_cmd_clean_orphans` only handles
`~/.codevira/projects/<key>/` data dirs whose `original_path` is invalid.
When tests/temp dirs are deleted but their `global.db` row remains, that
row is invisible to `clean --orphans` and pollutes `codevira projects`
output forever. Yesterday I had to run direct SQL to clean 13 such rows
on Sachin's machine.

**Plan:**

In `_cmd_clean_orphans` (lines 2297+):

1. After the existing per-data-dir loop, ALSO scan `global.db.projects`
   for rows whose `path` doesn't exist on disk AND has no corresponding
   data dir under `~/.codevira/projects/`.
2. List them in the same "Found N orphan project data dir(s)" output
   (relabel: "Found N orphan(s): M data dirs, K bare global.db rows").
3. Same confirmation prompt + dry-run support.

**Files touched:**
- `mcp_server/cli.py::_cmd_clean_orphans`
- Tests: assert bare rows ARE removed; assert real projects' rows are NOT
  touched.

---

## Item 14 — `clean --ghosts` catches truly-empty data dirs

**Problem (yesterday's session).** The "ghost" classification in
`_project_inventory` requires `has_data_dir AND has_real_state` where
`has_real_state` = at least one of {config, metadata, graph, codeindex}.
The 3 sub-bootstrapped dirs Sachin had (e.g.,
`AgentStore/packages/runtime/`) contained ONLY an empty `graph/fixes.db`
shell — they failed the "has_real_state" check (none of the 4 markers
satisfied) so weren't classified as ghost. Fell through to "stale" or
nothing.

**Plan:**

Add a 4th cleanup category: **"empty"** — data dirs that exist but contain
no meaningful state (`{config, metadata, real graph nodes}` all missing or
empty). `clean --ghosts` should also handle these OR add new
`clean --empty-dirs` flag.

**Recommendation**: extend `clean --ghosts` to cover both — keeps the
single command, simpler UX. Update the classification in
`_project_inventory.py::ProjectEntry.status` to detect "empty" as a
variant of "ghost."

**Files touched:**
- `mcp_server/_project_inventory.py` — broaden ghost detection.
- `mcp_server/cli.py::_cmd_clean_ghosts` — include the empty-dir cases.
- Tests: create an empty data dir + verify clean --ghosts removes it.

---

## Item 15 — README rewrite (impressive + user-attracting)

**Problem.** Current README doesn't sell the product. Field-test reports
1 and 3 both reference a marketing-vs-reality gap (Report 1 §1, Report 3
§1). README is functional but not conversion-grade. The product has
multiple verified killer features (cross-session prompt injection,
do_not_revert that actually propagates, hybrid search) but the README
doesn't lead with them or prove them.

**Goal.** A README that makes someone who lands on the GitHub page in
2026 say "I want to install this right now."

**Structure (rewrite, not edit):**

1. **Hero block** — 2-line value prop + a static screenshot or animated
   GIF (recorded later — placeholder for v2.1.2; record for v2.2 launch):
   - Headline: *"Persistent memory for AI coding agents. Local-first.
     Cross-IDE. Free forever."*
   - Sub-headline: *"Claude Code, Cursor, Windsurf, and Antigravity all
     share the same brain on your machine — no signup, no cloud."*
   - Badges: PyPI version, Python supported versions, License (MIT),
     Tests passing, Stars (when warranted)

2. **The problem** (60 words) — "Your AI agent forgets your project
   between sessions. Worse, two different AI tools never share what they
   learned. You re-explain the architecture, re-debate decisions, re-
   discover the same bugs. Codevira solves that, locally."

3. **60-second proof** (the demo) — Either an animated GIF (preferred)
   OR a code snippet showing:
   - Session 1: AI in Claude Code records a decision via codevira
   - Quit Claude Code, open Cursor
   - Session 2: AI in Cursor recalls the decision automatically via
     prompt injection
   - Caption: *"This is impossible without codevira. Local-first, MIT-
     licensed."*

4. **Quick Start** (3 commands) — already exists, keep, but make sure
   it's the 3 simplest commands and they actually work on a fresh install:
   ```bash
   pipx install codevira
   cd <your-project>
   codevira setup -y
   ```

5. **What you get** — Keep the existing benefits section but lead with
   the killer feature (cross-session prompt injection) and weave in
   field-tester quotes:
   > "Cross-session prompt injection IS the right architecture. Pushing
   > context to the agent based on prompt content — instead of requiring
   > the agent to remember to query — is exactly the right shape."
   > — independent field tester, 2026-05-18

6. **Comparison table** — Codevira vs CLAUDE.md vs mem0 vs Claude
   Projects vs nothing. Adapt from the existing internal competitive
   landscape doc (`docs/internal/competitive-landscape.md`) but make it
   public-friendly + honest.

7. **Architecture in one paragraph + diagram** — Three layers
   (decision log, semantic search, graph) talking to MCP. Use the
   existing Mermaid diagram if present; clean it up.

8. **Daily-use commands you'll actually run** — Keep the existing table
   but add new v2.1.x entries: `codevira search-decisions`, `codevira
   list-decisions`, `codevira heal --decisions`, `codevira reset`,
   `codevira calibrate`, `codevira export decisions`.

9. **What's stable / what's known-limited** — Honest reality framing.
   Two columns: "Production-stable" vs "Best-effort or coming soon."
   Includes "graph tools are Python-first; multi-language coming in
   v2.1.3."

10. **Contributing / Roadmap / License** — Keep links to existing
    CONTRIBUTING.md, ROADMAP.md, MIT license.

**Constraints:**
- No marketing fluff. Every claim links to either runnable code or a
  field-test quote.
- Lead with what works, not what's coming.
- Include the 60-second demo as the SECOND scroll-fold (after hero,
  before quick-start) — that's the conversion moment.

**Files touched:**
- `README.md` — full rewrite (preserve git history; this isn't a tiny
  edit).
- NEW `docs/comparison.md` — extract the comparison table from internal
  competitive-landscape with a less-tactical framing.
- NEW `docs/screenshots/` directory for the demo GIF placeholder. (Real
  GIF recorded for v2.2 launch.)

**Estimated effort**: 4 hours focused work. Worth it — this is the page
that converts traffic into users.

---

## Item 16 — Track plan + decisions in the codebase

**Problem.** This plan currently lives at `/Users/sachin/.claude/plans/snuggly-waddling-shamir.md`
— private to one machine, lost on session end. Future contributors (or
me on a different machine) can't reference it. ROADMAP.md exists but
doesn't have a v2.1.2 section yet.

**Plan:**

1. **Copy this plan into the codebase** at `docs/plans/v2.1.2.md` —
   public, version-controlled, persistent.
2. **Update `ROADMAP.md`** — add a new "v2.1.2 — Trust Recovery + QoL"
   section that summarizes the 16 items and links to the detailed
   `docs/plans/v2.1.2.md` for the full implementation detail.
3. **Establish discipline going forward**: every `vX.Y.Z` release that
   has 3+ items gets its own `docs/plans/vX.Y.Z.md` written before
   implementation. Plan reviewed via PR before code lands.
4. **Cross-link CHANGELOG**: the `## [2.1.2]` CHANGELOG entry links to
   `docs/plans/v2.1.2.md` for full context.

**Files touched:**
- NEW `docs/plans/v2.1.2.md` (copy of this plan)
- `ROADMAP.md` — add v2.1.2 section
- `CONTRIBUTING.md` — add a "Release planning discipline" subsection
  pointing at `docs/plans/`
- `CHANGELOG.md` — `[2.1.2]` entry links to `docs/plans/v2.1.2.md`

---

## Item 17 — Rule extractor noise filter (n-gram → real-pattern only)

**Problem (Report 4 #7).** `top_signals.rules` returned literal substrings
as "patterns":
- `"recurring pattern — system foundation md"`
- `"recurring pattern — foundation md is"`
- `"recurring pattern — is a projection"`

These are sliding-window n-grams from filenames + prose, not actual
patterns. An AI session that treats them as guidance gets MISLED — direct
trust hit, same family as Item 9 (cross-project rules leak).

**Design:**

1. **Stopword filter** — strip common English stopwords ("is", "a", "the",
   "of", "and", "or", "to", "with") before grouping n-grams.
2. **Minimum semantic density** — require ≥2 NON-stopword tokens AND at
   least one content-word (noun/verb-shaped — heuristic: contains a
   letter and is ≥4 chars).
3. **Substring rejection** — if a candidate pattern is a substring of an
   already-emitted pattern with higher support, drop it (so "foundation
   md is" doesn't ride along with "system foundation md").
4. **Pre-code escape hatch** — when the project has 0 indexed source
   files, the n-gram extractor runs on prose / filenames only and is
   nearly always noise. Skip the rule-extractor pass entirely in that
   case (caller sees empty `learned_rules` + a `note` per Item 8's
   pattern, not garbage rules).

**Files touched:**
- `mcp_server/engine/signals.py` (or wherever `top_signals` rules are
  composed — same file as Item 6's truncation work).
- Find the n-gram extractor (likely `_collect_recurring_patterns` or
  similar) and add `_STOPWORDS` + density check + substring suppression.
- `tests/test_signals.py` (or per the existing test layout) — add a
  case: feed the 3 noisy strings from Report 4 + assert none of them
  appear in `top_signals.rules`.

**Severity**: HIGH (trust-poisoning, same family as Item 9). Ship in v2.1.2.

---

## Item 18 — Auto-clear placeholder Phase 1 when first real phase added

**Problem (Report 4 #6).** Fresh `codevira init` creates a default
"Phase 1: Initial Development" entry. To add a real Phase 1 you must
first `complete_phase(1)` on the placeholder — which leaves a permanent
pollution row in `completed_phases`. Tester worked around it but called
the friction out.

**Design:**

1. Mark the bootstrap-placeholder row with a `is_placeholder=1` column
   (new schema bool — additive, default 0).
2. When `add_phase()` is called AND the placeholder still exists AND has
   `status='active'` AND has zero linked decisions / session-logs / phase
   activity → silently `DELETE` the placeholder instead of leaving an
   orphan "complete" row.
3. When `complete_phase()` is called on the placeholder explicitly (the
   current path) AND no real phase has ever been added → emit a soft
   warning suggesting `add_phase()` directly next time.
4. Documentation update: README's "first hour" section explicitly says
   "you don't need to complete the placeholder Phase 1 — just
   `add_phase` your real one and we'll clean up."

**Files touched:**
- `indexer/sqlite_graph.py` — add `is_placeholder INTEGER DEFAULT 0`
  column to `phases` table (additive migration); flip flag on bootstrap.
- `mcp_server/tools/roadmap.py::add_phase` — silent placeholder
  cleanup before insert.
- `mcp_server/tools/roadmap.py::complete_phase` — soft warning on
  placeholder completion.
- Tests: assert add_phase from fresh init deletes the placeholder + keeps
  the new real phase as the only row.

---

## Item 19 — Fix `file_path: null` in recent_decisions when path exists

**Problem (Report 4 #8).** `get_session_context()` returned entries
with `file_path: null` even though the underlying decision row had a
populated `file_path`. Pure serialization bug — context is lost in
transit between SQLite read and JSON-out.

**Plan:**

1. Reproduce: record a decision with `file_path="src/foo.ts"`, then
   call `get_session_context` and inspect the `recent_decisions[]`
   entry for that decision.
2. Trace the field through: `SQLiteGraph.get_recent_decisions` (or
   wherever the read happens) → `get_session_context` JSON shaping →
   MCP tool response.
3. Find where the column is dropped or null-coerced. Likely candidates:
   a SELECT list that omits `file_path`; a dict-shape that pops/renames
   keys; or a Pydantic / dataclass that doesn't include the field.
4. Add a regression test that records a decision with a path + asserts
   `recent_decisions[0]['file_path'] == "src/foo.ts"`.

**Severity**: MEDIUM (context loss → AI can't follow the decision back
to its file). Easy fix once located. Ship in v2.1.2.

**Files touched:**
- Investigation likely points to `mcp_server/tools/learning.py::get_session_context`
  or `indexer/sqlite_graph.py::get_recent_decisions`.
- `tests/test_tools_learning.py` — regression test.

---

## Item 20 — `check_conflict(decision_text)` proactive conflict detection

**Problem (Reports 3 + 4 #3).** Recording a decision that contradicts a
prior `do_not_revert=True` decision succeeds silently. Same family as
Report 4's write-time dedup — both ask "is this decision novel /
non-conflicting before I write it?"

**Design:**
1. NEW MCP tool `check_conflict(decision_text, file_path=None)` →
   returns `{conflicts: [{decision_id, similarity, do_not_revert,
   summary}], duplicates: [...], status: "novel"|"duplicate"|"conflict"}`.
2. Uses Item 1's calibrated semantic threshold. A *duplicate* is
   similarity ≥ threshold to ANY decision; a *conflict* is similarity
   ≥ threshold to a `do_not_revert=True` decision.
3. ALSO hook into `record_decision`: when called, run check_conflict
   internally and include `_conflict_warning` in the response if hits
   found. Optional `force=True` arg suppresses the warning.

**Files touched:**
- NEW `mcp_server/tools/check_conflict.py` (or extend `search.py`)
- `mcp_server/tools/learning.py::record_decision` — pre-write check
- `mcp_server/server.py` — register MCP tool
- Tests: round-trip "record A with do_not_revert; record contradicting
  B; assert B response carries _conflict_warning"

---

## Item 21 — Multi-language `get_signature` / `get_code` via tree-sitter

**Problem (Reports 1+2+3, D grade for graph tools).** Both tools only
extract Python via the AST module. TypeScript / Go / Rust / Java files
return empty results. Reports 1 + 2 both gave graph tools a D specifically
because "Python-only" is a non-starter for polyglot projects.

**Design:**
1. Drop-in `tree-sitter-languages` (or per-language grammars: tree-sitter-python,
   tree-sitter-typescript, tree-sitter-go, tree-sitter-javascript) at
   `pyproject.toml` deps.
2. Refactor `indexer/symbol_extractor.py` (or wherever Python AST is
   used) to dispatch by language: Python → ast, TS/JS/Go → tree-sitter.
3. `get_signature(file, name)` walks the tree-sitter parse tree for
   function/class defs of name; returns the same shape as Python today.
4. `get_code(file, name)` returns the source byte-range of the matched
   def.

**Files touched:**
- `pyproject.toml` — add tree-sitter + grammars
- `indexer/symbol_extractor.py` (NEW or refactor existing)
- `mcp_server/tools/code.py::get_signature`, `get_code` — dispatch by
  file extension
- Tests: round-trip extraction for .ts, .go, .js, .py — assert same
  response shape

---

## Item 22 — Session ID collision handling

**Problem (Reports 3+4).** Sessions use 8-char freehand slugs
(`"origin"`, `"arch-reframe"`). Two AI tools can pick the same slug;
behavior is undefined.

**Design:**
1. On `write_session_log(session_id=...)`, check if `session_id` already
   exists in `sessions` table.
2. If yes AND content differs (hash mismatch) → auto-suffix with
   `-<short-hash>` and return the actual session_id in the response
   payload.
3. If yes AND content matches → idempotent return (same id).
4. Document in MCP tool docstring + CHANGELOG.

**Files touched:**
- `indexer/sqlite_graph.py::start_session` (or whichever writes session
  rows) — collision check + auto-suffix
- `mcp_server/tools/learning.py::write_session_log` — surface the
  actual session_id in response
- Tests: write twice with same id + different content → second returns
  different id

---

## Item 23 — `record_decisions([...])` batch endpoint

**Problem (Report 4 #2).** A memory-dump session today required ~26
separate `record_decision` round trips (~95% token waste vs a single
batch call).

**Design:**
1. NEW MCP tool `record_decisions(decisions: list[dict])` where each
   dict has the same shape as the current `record_decision` args.
2. Single transaction in `indexer/sqlite_graph.py` — all-or-nothing.
3. Embedding batch in `_decision_embeddings.backfill_all_decisions`
   style — one ChromaDB call for the whole batch.
4. Response shape: `{count: N, ids: [...], errors: [{idx, error}]}`
   where partial failure surfaces per-item errors but the transaction
   succeeded for the rest.

**Files touched:**
- `indexer/sqlite_graph.py` — add `record_decisions_batch(...)` method
  using executemany
- `mcp_server/tools/learning.py::record_decisions` — new MCP tool
- `mcp_server/server.py` — register
- Tests: 10-item batch round-trip, partial-failure (one bad row),
  embedding-side-effect assertion

---

## Item 24 — `write_session_logs([...])` batch endpoint

**Problem (Report 4 #2).** Same as #23 for session logs.

**Design:** Same pattern as #23. Batch transaction; one summary row per
log; embed all decisions in one ChromaDB call.

**Files touched:** Mirror of #23 in `write_session_log` paths.

---

## Item 25 — `since=timestamp` filter on session_context / search / history

**Problem (Report 4 #9).** Every session pulls "recent" with no way to
ask "what is new since I was last here." Forces a full re-read.

**Design:**
1. Add optional `since: str` (ISO 8601) param to:
   - `get_session_context(since=...)` — filters `recent_decisions` +
     `recent_session_logs`
   - `search_decisions(since=...)` — adds WHERE created_at > ? to the
     SQL filter (applies to both BM25 + semantic paths)
   - `get_history(since=...)` — if get_history is the existing tool
     that returns decision timeline; otherwise no-op
2. Match the same `since_date` shape Item 11 (`list_decisions`) already
   uses — single source of truth.

**Files touched:**
- `mcp_server/tools/learning.py::get_session_context`
- `mcp_server/tools/search.py::search_decisions`
- `mcp_server/tools/learning.py::get_history` (if it exists; otherwise
  scope to the two above)
- `indexer/sqlite_graph.py` — propagate since param through queries
- Tests: write 5 decisions with `time.sleep(0.1)` between → assert
  since= filter returns exactly the expected slice

---

## Item 26 — `supersede_decision(old_id, new_decision, reason)` + soft-delete

**Problem (Report 4 #4).** Recording a wrong decision can only be
counter-recorded. The original stays. Decision history becomes
contradictory over time.

**Design:**
1. New column on `decisions`: `superseded_by INTEGER` (FK to
   `decisions.id`) + `is_superseded INTEGER DEFAULT 0` (visibility
   flag).
2. NEW MCP tool `supersede_decision(old_id, new_decision, reason)`:
   - Records the new decision normally (returns new_id).
   - Sets `superseded_by = new_id, is_superseded = 1` on the old row.
   - Persists the `reason` in the new decision's text prefix:
     `"[supersedes #<old_id>: <reason>] <new_text>"`.
3. ALL search / list / get_session_context paths filter
   `WHERE is_superseded = 0` by default. New param `include_superseded=True`
   to opt back in (for audit / history).

**Files touched:**
- `indexer/sqlite_graph.py` — schema migration (ALTER TABLE +
  retro-flag rows that already have content "[supersedes #..."), new
  `supersede_decision()` method
- `mcp_server/tools/learning.py::supersede_decision` — new MCP tool
- ALL read-path filter updates (search, list, session_context, history)
- Tests: supersede + assert old hidden + assert
  include_superseded=True returns both

---

## Item 27 — `tags: [...]` field on decisions + filter API

**Problem (Report 4 #5).** Free-text decisions → can't reliably ask
"show all design-system decisions" or "all Phase-0 decisions."

**Design:**
1. New table `decision_tags(decision_id INTEGER, tag TEXT, PRIMARY
   KEY(decision_id, tag))` — many-to-many indexed both directions.
2. `record_decision(..., tags=["design-system", "phase-0"])` accepts
   a list of tag strings; inserts into `decision_tags`.
3. `search_decisions(..., tags=["design-system"])` — INTERSECT
   matching tag set with the search results.
4. `list_decisions(..., tags=["phase-0"])` (Item 11) — same filter.
5. NEW MCP tool `list_tags()` → returns `{tag: count}` map for
   discovery.
6. Tag normalization: lowercase + strip + dedupe at write time.

**Files touched:**
- `indexer/sqlite_graph.py` — new table + migration + tag CRUD methods
- `mcp_server/tools/learning.py::record_decision` — accept tags param
- `mcp_server/tools/search.py::search_decisions` — tag filter
- NEW `mcp_server/tools/list_tags.py`
- `mcp_server/server.py` — register list_tags
- Tests: round-trip tags through record → search → list

---

## Item 28 — `summary_only=True` mode on search_decisions

**Problem (Report 4 token-savings table).** Default search response
returns full decision text; for triage queries the AI only needs
id + summary. ~70% smaller payload possible.

**Design:**
1. Add `summary_only: bool = False` param to `search_decisions`.
2. When True, response omits `decision_text`, `file_path`, `tags`, etc.
   Returns only `[{id, summary, score, do_not_revert}, ...]`.
3. Document the token saving in the tool docstring so AIs learn to use
   it for triage.

**Files touched:**
- `mcp_server/tools/search.py::search_decisions` — param + projection
- Tests: assert truncated response shape

---

## Item 29 — `bulk_import_phases([...])` for roadmap migration

**Problem (Report 3 #5).** Adopters with multi-phase git history need
to backfill phases one at a time. Item 10 (--backfill on complete_phase)
helps but is still one-by-one.

**Design:**
1. NEW MCP tool `bulk_import_phases(phases: list[dict])` where each
   dict has `{number, title, status, completed_at?, key_decisions?,
   git_ref?}`.
2. Single transaction. Inserts/updates by phase number.
3. Idempotent — re-running with the same phases is a no-op.

**Files touched:**
- `indexer/sqlite_graph.py` — bulk insert with ON CONFLICT
- `mcp_server/tools/roadmap.py::bulk_import_phases` — new MCP tool
- Tests: 4-phase backfill from an empty roadmap

---

## Item 30 — `record_decision` echo hint when `do_not_revert` looks ambiguous

**Problem (Report 2, tester later flagged as their own bug — but the UX
hint still has value).** If a user passes truthy-looking-but-not-bool
values for `do_not_revert` (e.g. the string `"true"` or `1`), record
silently coerces. An echo in the response makes the actual stored value
visible.

**Design:**
1. After write, the response always includes
   `do_not_revert: <actual bool stored>`.
2. If the caller's input was anything other than `True`/`False` (e.g.
   string, int), include a `_input_coerced_warning: "do_not_revert
   passed as <type>; coerced to <bool>"` field in the response.

**Files touched:**
- `mcp_server/tools/learning.py::record_decision` — input-shape sniff +
  warning field
- Tests: pass int 1 / string "true" / bool True → assert warning only
  fires on non-bool

---

## Item 31 — Bundled non-Python playbooks (TypeScript / Go / generic)

**Problem (Report 1 §3.5).** Per-project playbooks work but the bundled
defaults are Python-only. Adopters in TS / Go projects must hand-write
from scratch.

**Design:**
1. NEW `mcp_server/playbooks/typescript.md`, `go.md`, `generic.md` —
   each ~200 lines, structured the same as the existing Python playbook.
2. `codevira init --language <ts|go|generic>` writes the matching
   playbook into the project's data dir.
3. Auto-detect on `init` (no --language flag): look at file extensions
   in project root, pick the dominant one, default to `generic`.

**Files touched:**
- NEW `mcp_server/playbooks/typescript.md`
- NEW `mcp_server/playbooks/go.md`
- NEW `mcp_server/playbooks/generic.md`
- `mcp_server/cli.py::cmd_init` — language detection + playbook write
- `MANIFEST.in` — include the playbook md files in the wheel
- Tests: init in a TS-dominated project → assert TS playbook written

---

## Item 32 — Mypy 36 baseline type errors

**Problem (earlier triage).** Project has 36 baseline mypy errors. Slow
drift; not user-visible but blocks tightening the mypy gate over time.

**Design:**
1. Audit each error; categorize: "real bug fixable in 1 line", "needs
   type narrowing", "needs type stubs for 3rd-party".
2. Fix the in-1-line group (~20 errors expected).
3. Add `# type: ignore[reason]` with explicit reason for the rest.
4. Set mypy `--strict-equality` + `--warn-redundant-casts` after baseline
   clears so future errors fail CI.

**Files touched:**
- Across the source tree — find with `mypy mcp_server indexer`
- `Makefile` — add `mypy-strict` target
- `pyproject.toml` — pin mypy config

---

## Item 33 — Hook substring-match precision (commit-message false positives)

**Problem (earlier triage).** Hook injects context on
commit-message-like prompts that happen to contain a decision keyword.
Cosmetic; the override flag works but tightening reduces friction.

**Design:**
1. In `mcp_server/engine/policies/cross_session.py::_collect_matches`,
   add a regex pre-filter: if the prompt looks like a commit message
   (matches `^(feat|fix|chore|docs|refactor|test|style)(\(.*\))?:`),
   skip the substring match entirely.
2. Otherwise use the existing substring matcher.

**Files touched:**
- `mcp_server/engine/policies/cross_session.py` — pre-filter regex
- Tests: assert `"feat(api): add endpoint"` does NOT trigger context
  injection; assert `"how did we decide the api endpoint?"` DOES.

---

## Documentation discipline (applies to ALL 14+ items)

Per the user's explicit instruction: **every code change in v2.1.2 must
update the relevant user-facing documentation in the same commit.** Not
deferred, not "we'll write docs later."

For each item, the docs that must be touched:

| Item | Docs to update |
|---|---|
| #1 (smart threshold) | README "What you get" (mention `retrieval` field + threshold), `docs/release-process.md` (calibrate command), CHANGELOG |
| #2 (null vs 0) | `MIGRATING.md` (call out null return for unindexed paths as v2.1.x → v2.1.2 behavior change), MCP tool docstrings in `graph.py`, CHANGELOG |
| #3 (heal → reset) | README "Recovery" section (NEW), `MIGRATING.md` (heal-deprecation notice), `docs/release-process.md`, CHANGELOG |
| #4 (post-edit refresh) | `docs/hooks.md` (NEW — currently a v2.1 roadmap item that never shipped — write it now), CHANGELOG |
| #5–8 (QoL wins) | CHANGELOG only (no behavior changes that need separate docs) |
| #9 (cross-project scoping) | NEW `docs/project-scoping.md` explaining what's per-project vs machine-global. README "What you get" if relevant. CHANGELOG |
| #10 (--backfill) | `docs/roadmap.md` (existing per-project roadmap docs) + tool docstring + CHANGELOG |
| #11 (list_decisions) | README commands table, tool docstring, CHANGELOG |
| #12 (git_ref) | `docs/roadmap.md` + tool docstring + CHANGELOG |
| #13–14 (clean) | `docs/release-process.md` (if it covers cleanup), CHANGELOG |
| #15 (README rewrite) | README (the rewrite itself) + NEW `docs/comparison.md` |
| #16 (plan in codebase) | NEW `docs/plans/v2.1.2.md` + `ROADMAP.md` + `CONTRIBUTING.md` |
| #17 (rule extractor noise) | CHANGELOG; if `docs/project-scoping.md` from #9 exists, add an "anti-noise filters" section to it |
| #18 (placeholder phase auto-clear) | README "first hour" walk-through (mention placeholder cleanup), `docs/roadmap.md`, CHANGELOG |
| #19 (file_path serialization) | CHANGELOG (bug-fix only — no new behavior docs) |
| #20 (check_conflict) | README "What you get" + new tool docstring + CHANGELOG |
| #21 (multi-lang get_signature) | README ("Production-stable" matrix now lists TS/JS/Go/Python) + CHANGELOG |
| #22 (session_id collisions) | Tool docstring on write_session_log + CHANGELOG |
| #23 (record_decisions batch) | README commands table + tool docstring + token-savings note in CHANGELOG |
| #24 (write_session_logs batch) | Same as #23 |
| #25 (since= filter) | Tool docstrings on 3 endpoints + CHANGELOG |
| #26 (supersede + soft-delete) | NEW `docs/decision-lifecycle.md` (record → supersede → audit) + README "Recovery" section + CHANGELOG |
| #27 (tags) | README "What you get" + NEW `docs/tagging.md` (conventions: lowercase, kebab-case, recommended starter tags) + CHANGELOG |
| #28 (summary_only mode) | Tool docstring (highlight token saving) + CHANGELOG |
| #29 (bulk_import_phases) | `docs/roadmap.md` (alongside --backfill) + CHANGELOG |
| #30 (record_decision echo) | Tool docstring + CHANGELOG |
| #31 (bundled playbooks) | README "Quick Start" (mention auto-detect) + NEW `docs/playbooks/README.md` + CHANGELOG |
| #32 (mypy baseline) | CONTRIBUTING.md ("type-check before commit") + CHANGELOG |
| #33 (hook regex precision) | `docs/hooks.md` (from #4) + CHANGELOG |

**Verification at commit time**: pre-commit check OR PR template
checklist asking "Did this PR update the docs listed in the plan for
this item?" Probably a manual checklist for now; automate later.

---

## Implementation order (incremental, each verifiable)

1. **#2** (graph null vs 0) — pure return-shape change, lowest risk, fastest
   feedback signal.
2. **#5** (type coerce 1→true) — trivial, lock in early.
3. **#6** (truncation), **#7** (summary), **#8** (hide empty) — small, batch
   together.
4. **#3a + 3e** (export helper + standalone command) — foundational for #3b.
5. **#3b + 3c + 3d** (reset command + deprecation + typed confirm) —
   builds on #3a.
6. **#1** (smart threshold) — biggest unknown; build with static defaults
   first, add calibration second.
7. **#4** (post-edit graph refresh) — last because it touches the engine
   policy chain.
8. **#9** (cross-project rules leak) — start with the 30-min audit. If
   reproducible, fix immediately; if not, document scoping in
   CHANGELOG + close.
9. **#10** (complete_phase --backfill) — schema change is small;
   parameter threading is straightforward.
10. **#11** (list_decisions) — pure additive; 30 min as Report 3 promised.
11. **#12** (complete_phase git_ref) — small schema migration; pair with
    #10 in a single sqlite_graph.py edit.
12. **#13 + #14** (clean orphan rows + empty data dirs) — pair them in
    the same cleanup commit since both touch the inventory helper.
13. **#17** (rule extractor noise) — pair with #6 (truncation) or #9
    (cross-project leak) since all three touch `engine/signals.py` and
    are trust-recovery items.
14. **#18** (placeholder phase auto-clear) — pair with #10 (--backfill)
    + #12 (git_ref) in a single `sqlite_graph.py + roadmap.py` edit
    since all three touch the same files.
15. **#19** (file_path serialization fix) — slot between #11
    (list_decisions) and the build step; small bug fix, isolated test.
16. **#20** (check_conflict) — depends on #1's calibrated threshold;
    schedule after #1 ships.
17. **#22** (session_id collisions) — small standalone fix in
    sqlite_graph.py + tools/learning.py.
18. **#23 + #24** (batch endpoints) — pair them in one
    `tools/learning.py` edit; both use the same batch-transaction
    pattern.
19. **#25** (since= filter) — pair with #11 (list_decisions) since
    both touch the same SQL filter shape.
20. **#26 + #27** (supersede + tags) — pair them in one schema
    migration commit since both touch the `decisions` table. Do tags
    first (simpler), then supersede (which needs to know about tags
    for its is_superseded filter to play well with tag queries).
21. **#28** (summary_only mode) — slot after #25 since both touch
    `search_decisions`.
22. **#29** (bulk_import_phases) — pair with #10 + #12 + #18 in the
    same roadmap-tools commit.
23. **#21** (multi-language graph) — single focused commit; new
    dep + grammar files + symbol_extractor refactor. Run BEFORE #15
    (README rewrite) so the README's "what's stable" claim can drop
    "Python-first."
24. **#30** (record_decision echo) — trivial polish; can land any time.
25. **#31** (bundled playbooks) — three new md files + cmd_init
    detection. Independent of everything else.
26. **#32** (mypy baseline) — incremental; do AFTER all functional
    items so type-checker doesn't churn against in-flight code.
27. **#33** (hook regex precision) — small cosmetic fix; pair with
    #4 in the engine-policies commit.
28. **#16** (plan in codebase) — copy plan to `docs/plans/v2.1.2.md` +
    update ROADMAP.md early in the cycle so contributors can reference
    the plan while it's being implemented. Update as items complete.
29. **#15** (README rewrite) — LAST in the order. By the time we get
    here, all the v2.1.2 capabilities are shipped and verified. The
    README rewrite needs accurate command output + working examples,
    which requires everything else first.

---

## Critical files

| File | Purpose |
|---|---|
| `mcp_server/tools/_decision_embeddings.py` | Threshold + calibration core |
| `mcp_server/tools/search.py` | Thread scores into response |
| `mcp_server/tools/graph.py` | null-not-zero for get_node/get_impact/query_graph |
| `mcp_server/engine/policies/cross_session.py` | Stricter threshold for hook injection |
| `mcp_server/cli.py` | Register new `reset`, `export`, `calibrate` subparsers; deprecate destructive heal |
| `mcp_server/_prompts.py` | Add `confirm_typed` helper |
| `mcp_server/engine/__init__.py` | Register new PostToolUse policy |
| NEW `mcp_server/cli_export.py` | Standalone export + shared backup helper |
| NEW `mcp_server/cli_calibrate.py` | `codevira calibrate --decisions` |
| NEW `mcp_server/cli_reset.py` (or inline in cli.py) | `cmd_reset` |
| NEW `mcp_server/engine/policies/post_edit_refresh.py` | Auto graph refresh |
| `indexer/sqlite_graph.py` | Type coerce on read; better default summary |
| `indexer/index_codebase.py` | `refresh_single_node` helper |
| `mcp_server/engine/signals.py` | Smart truncate for top_signals.rules |

---

## Verification

### Per-item smoke tests (must all pass before commit)

| Item | Test |
|---|---|
| #1 search threshold | `codevira` → call `search_decisions("zzzzzz nonexistent gibberish xqzv9")` → expect `count: 0, retrieval: "semantic-no-results-above-threshold"`. Then `search_decisions("DDD architecture layer")` → expect count > 0 + score field per result. |
| #1 hook injection | Submit prompt `"how to make a cake"` → engine hook returns empty additionalContext OR a single explanatory line, NOT the 5-decision list. |
| #1 calibration | `codevira calibrate --decisions --dry-run` → reports current threshold + proposed threshold + sample count. |
| #2 null vs 0 | `get_node("nonexistent/file.ts")` → returns `not_indexed: True, dependencies_count: None` (not 0). |
| #3a auto-export | `codevira reset --graph --dry-run` → reports the planned export path + counts + warns "would back up to: ..." |
| #3b reset | `codevira reset --vectors --yes` → wipes codeindex/, exports decisions first. Verify `<data_dir>/exports/<timestamp>.json` exists. |
| #3c deprecation | `codevira heal --vectors` → prints deprecation warning, still wipes (for now), exports. |
| #3d typed confirm | `codevira reset --graph` (no --yes) → must require typing `reset` to proceed. |
| #3e standalone export | `codevira export decisions --out /tmp/d.json` → file exists, parses as valid JSON, count matches `SELECT COUNT(*) FROM decisions`. |
| #4 post-edit refresh | Edit a file via Claude Code's Edit tool → `codevira get_node(<path>)` shows `stale: False` within 5 seconds; `dependencies_count` reflects new imports. |
| #5 type coerce | `record_decision(..., do_not_revert=True)` response shows `"do_not_revert": true` (bool). `search_decisions` returns `True`/`False` not `1`/`0`. |
| #6 truncation | After recording a rule like `"Files in 'packages/runtime/src/core/' should have tests in 'packages/runtime/test/unit/'"`, `top_signals.rules` shows the path ending in `unit/`, not `uni…`. |
| #7 summary | `record_decision("Chose 4-layer DDD architecture for cortex")` → subsequent `search_decisions` returns `summary: "Chose 4-layer DDD architecture for cortex"` (not "ad-hoc record_decision"). |
| #8 hide empty | Fresh project + 5 decisions → `get_session_context` omits `confidence` block (or shows note instead of `confidence.total_decisions: 0`). |
| #9 cross-project leak | Record a rule via `record_preference` in Project A, switch to Project B, call `get_session_context` → must NOT show the Project-A rule. |
| #10 backfill | `complete_phase(phase=2, backfill=True, completed_at="2026-04-15")` on a project where current_phase=5 → phase 2 marked done, current_phase stays 5. |
| #11 list_decisions | `codevira list-decisions --limit 5 --protected-only` returns N protected decisions; without flag returns mix. |
| #12 git_ref | `complete_phase(phase=4, git_ref="abc123def")` → `get_phase(4)` shows `git_ref` + `git_show_command`. |
| #13 clean bare rows | Create a global.db row pointing at deleted path + no data dir → `clean --orphans` lists + removes it. |
| #14 clean empty dirs | Create data dir with only an empty `graph/` subdir → `clean --ghosts` catches + removes. |
| #15 README rewrite | Fresh visitor on GitHub: hero block + 60-sec demo visible in first scroll; quick-start runs cleanly on a clean machine. Manually inspect rendered README on github.com. |
| #16 plan in codebase | `docs/plans/v2.1.2.md` exists, mirrors `~/.claude/plans/snuggly-waddling-shamir.md`. ROADMAP.md has a v2.1.2 section. CONTRIBUTING.md mentions the planning discipline. |
| #17 rule noise filter | Feed the 3 Report-4 noisy strings to the rule extractor → none appear in `top_signals.rules`. Pre-code project (0 source files) → `learned_rules` empty, no n-gram garbage. |
| #18 placeholder cleanup | Fresh `codevira init` → `add_phase(...)` → `get_roadmap()` shows ONLY the new phase, no orphan placeholder row. |
| #19 file_path serialization | `record_decision(..., file_path="src/foo.ts")` → `get_session_context()` recent_decisions[0]['file_path'] == "src/foo.ts" (not null). |
| Report-4 retrieval gauntlet | After Item 1 calibration ships: `search_decisions("PostgreSQL pgvector database")`, `search_decisions("event sourcing schema tenant")`, `search_decisions("monorepo Turborepo packages")` — all 3 must return hits when the corresponding decisions exist (currently return 0 in v2.1.1). |
| #20 check_conflict | Record `do_not_revert=True` decision "Use PostgreSQL with pgvector". Call `check_conflict("Switch to MongoDB")` → returns conflicts: [{do_not_revert: true, ...}]. Then `record_decision("Switch to MongoDB")` → response includes _conflict_warning. |
| #21 multi-lang signature | Write a `.ts` file with `export function foo(): string {}` → `get_signature("foo.ts", "foo")` returns the signature. Same for `.go` and `.js`. |
| #22 session_id collisions | `write_session_log(session_id="arch")` with content A → returns session_id "arch". Call again with content B → returns session_id "arch-<hash>" (auto-suffixed). |
| #23 record_decisions batch | 10-decision batch → response returns 10 ids in one round trip; SQLite query confirms 10 rows in `decisions`; ChromaDB confirms 10 embeddings. |
| #24 write_session_logs batch | Same pattern as #23 for session logs. |
| #25 since= filter | Write 3 decisions at t=0, sleep 1s, write 3 more. `search_decisions(query="...", since="<midpoint ISO>")` returns only the 3 newer. Same for `get_session_context` and `get_history`. |
| #26 supersede | Record decision A, supersede_decision(A.id, "new B", reason="design changed"). Then `search_decisions("...")` doesn't return A; with include_superseded=True, returns both. |
| #27 tags | record_decision with tags=["design", "phase-0"]; list_decisions(tags=["design"]) returns it; list_tags() returns {design: 1, phase-0: 1}. |
| #28 summary_only | search_decisions("foo", summary_only=True) response has no `decision_text` key; size is <30% of full response. |
| #29 bulk_import_phases | bulk_import_phases([4 phases]) on empty roadmap → get_roadmap() returns 4 phases with correct numbers / statuses. |
| #30 record_decision echo | record_decision(do_not_revert=1) → response carries _input_coerced_warning. record_decision(do_not_revert=True) → no warning field. |
| #31 bundled playbooks | `codevira init` in a TS-heavy project → playbook in data dir starts with TypeScript-specific content. |
| #32 mypy baseline | `make mypy-strict` exits 0 on the whole repo. |
| #33 hook regex precision | UserPromptSubmit hook with prompt `"feat(api): add foo"` → engine response has no additionalContext. With `"how did we decide the api?"` → engine returns context. |

### Release gauntlet

After all items merged, run on the full repo:
```bash
PYTHON=/Users/sachin/.local/pipx/venvs/codevira/bin/python make release-gauntlet
```

Expected: G1 unit tests PASS (existing 2393 + new ~25), G2 e2e PASS,
G3 skip (stub), G4 warn (historical).

### Trial install (post-commit, pre-publish)

```bash
rm -rf dist build
PYTHON=/Users/sachin/.local/pipx/venvs/codevira/bin/python make release-build
TMP=$(mktemp -d) && /usr/local/bin/python3.13 -m venv $TMP/venv
$TMP/venv/bin/pip install dist/codevira-2.1.2-py3-none-any.whl
$TMP/venv/bin/codevira --version    # expect: 2.1.2
$TMP/venv/bin/codevira reset --help # confirms new command
$TMP/venv/bin/codevira export --help
$TMP/venv/bin/codevira calibrate --help
```

### End-to-end benchmark replay (the key trust-recovery proof)

Against the user's own LogisticsOS / lh-interface decisions:

1. `codevira search_decisions "zzzzzz nonexistent gibberish xqzv9"` → 0 results
2. `codevira search_decisions ""` → 0 results
3. `codevira search_decisions "how to make a cake"` → 0 results (or
   irrelevant explanation note)
4. `codevira search_decisions "DDD architecture layer"` → real hit with
   score ≥ threshold
5. `codevira reset --graph --dry-run` → shows backup plan with decision
   count

If 1–5 all behave as expected, trust recovery is verified.

---

## Explicitly DEFERRED to v2.1.3 / v2.2

Per user instruction "why cannot we take deferred item in the current
plan as everyone has reported the same", **every previously-deferred
item has been promoted into v2.1.2 (items 20–33)**. This section is
intentionally empty as of fourth-pass. v2.1.3 is reserved for whatever
shakes out post-launch; no items pre-allocated.

The only remaining v2.2-class items are launch-coupled docs:

| Item | From | Why v2.2 |
|---|---|---|
| **README "92% reduction" benchmark + memory-claims rewrite** | Report 1 §1 marketing-vs-reality | Item 15 (README rewrite) lands honest framing in v2.1.2. The numeric benchmark claim ("92% reduction") needs the formal benchmark suite to actually publish — pair with v2.2 launch / benchmark publishing work. |
| **Animated 60-second demo GIF** | Item 15 v2.1.2 placeholder | Recording quality bar is high; capture once for the v2.2 launch announcement rather than rushed for v2.1.2 ship. |

---

## Versioning + release

- Bump `pyproject.toml` + `mcp_server/__init__.py` → `2.1.2`
- Promote `CHANGELOG.md [Unreleased]` → `## [2.1.2] — <date>`
- Single comprehensive commit (atomic-commits discipline applied where
  practical; folded into one if pre-commit churn forces it)
- Tag `v2.1.2`
- Generate `.release-evidence/2.1.2.json`
- Trial wheel install in fresh venv (verify `codevira --version` ==
  `2.1.2` + new commands work)
- Hand off for Sachin to G5 + push + twine upload
