# CRUD Gaps: Note Delete, Glossary Edit, Fact Delete/Edit — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add four missing CRUD operations at the library, Python API, and CLI levels: note delete, glossary edit, fact delete, and fact edit.

**Architecture:** Each operation follows the existing pattern: library function in the relevant module (`glossary.py` for glossary, new `cli.py` helpers for notes/facts) → Python API method in `api.py` → CLI command in `cli.py`. All file writes are atomic (temp-file-then-rename). Facts sync between SQLite `facts` table and entity markdown files. Notes are deleted from both filesystem and the SQLite index. The `usage()` function in `cli.py` is updated for every CLI change.

**Tech Stack:** Python 3.10+, Click, Pydantic v2, SQLite, pytest. No new dependencies.

---

## Task 1: Glossary Edit — Library Function

**Files:**
- Modify: `src/kb/glossary.py` (add `edit_term()` after `delete_term()`, line ~71)
- Test: `tests/test_glossary.py`

**Step 1: Write the failing tests**

Add to `tests/test_glossary.py`:

```python
class TestGlossaryEdit:
    def test_edit_term_updates_expansion(self, glossary_env):
        from kb.glossary import edit_term

        _db, root = glossary_env
        result = edit_term(root, "AC", "Acme Corporation")
        assert result == {"term": "AC", "expansion": "Acme Corporation", "section": "Acronyms"}
        content = (root / "memory" / "glossary.md").read_text()
        assert "| AC | Acme Corporation |" in content
        assert "| AC | Acme Corp |" not in content

    def test_edit_term_not_found_raises(self, glossary_env):
        from kb.glossary import edit_term

        _db, root = glossary_env
        with pytest.raises(ValueError, match="Term not found"):
            edit_term(root, "NONEXISTENT", "whatever")

    def test_edit_term_preserves_other_rows(self, glossary_env):
        from kb.glossary import add_term, edit_term

        _db, root = glossary_env
        add_term(root, "SCRT", "Secrets Detection", section="Acronyms")
        edit_term(root, "AC", "Acme Corporation")
        content = (root / "memory" / "glossary.md").read_text()
        assert "| SCRT | Secrets Detection |" in content
        assert "| AC | Acme Corporation |" in content
```

**Step 2: Run tests to verify they fail**

Run: `cd kbx && uv run pytest tests/test_glossary.py::TestGlossaryEdit -x -v`
Expected: FAIL — `ImportError: cannot import name 'edit_term'`

**Step 3: Write minimal implementation**

Add to `src/kb/glossary.py` after `delete_term()`:

```python
def edit_term(project_root: Path, term: str, expansion: str) -> dict[str, Any]:
    """Update the expansion of an existing glossary term. Preserves position.

    Raises ValueError if the term is not found.
    """
    content = _read_glossary(project_root)
    pattern = rf"^\| *{re.escape(term)} *\|[^|]*\|"
    replacement = f"| {term} | {expansion} |"
    new_content, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE)
    if count == 0:
        raise ValueError(f"Term not found: {term}")
    _write_glossary(project_root, new_content)

    # Determine section for return value
    section = "Unknown"
    for match in re.finditer(r"^## (.+)$", new_content, re.MULTILINE):
        current_section = match.group(1).strip()
        rest = new_content[match.end():]
        if f"| {term} |" in rest.split("\n## ")[0] if "\n## " in rest else rest:
            section = current_section
            break
    return {"term": term, "expansion": expansion, "section": section}
```

**Step 4: Run tests to verify they pass**

Run: `cd kbx && uv run pytest tests/test_glossary.py -x -v`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/kb/glossary.py tests/test_glossary.py
git commit -m "feat(glossary): add edit_term() library function"
```

---

## Task 2: Glossary Edit — Python API + CLI

**Files:**
- Modify: `src/kb/api.py` (add method after `list_glossary_terms()`, line ~510)
- Modify: `src/kb/cli.py` (add `glossary edit` command after `glossary_delete`, line ~1221; update `usage()`)
- Test: `tests/test_api.py`

**Step 1: Write the failing API test**

Add to `tests/test_api.py` inside `class TestGlossary`:

```python
    def test_edit_glossary_term(self, kb_instance, project_root):
        (project_root / "memory" / "glossary.md").write_text(
            "# Glossary\n\n## Acronyms\n\n| Term | Expansion |\n"
            "|------|----------|\n| API | Application Programming Interface |\n"
        )
        result = kb_instance.edit_glossary_term("API", "App Programming Interface")
        assert result == {"term": "API", "expansion": "App Programming Interface", "section": "Acronyms"}
        # Verify it persisted
        terms = kb_instance.list_glossary_terms()
        assert terms[0].expansion == "App Programming Interface"

    def test_edit_glossary_term_not_found(self, kb_instance, project_root):
        with pytest.raises(ValueError, match="Term not found"):
            kb_instance.edit_glossary_term("NOPE", "whatever")
```

**Step 2: Run tests to verify they fail**

Run: `cd kbx && uv run pytest tests/test_api.py::TestGlossary::test_edit_glossary_term -x -v`
Expected: FAIL — `AttributeError: 'KnowledgeBase' object has no attribute 'edit_glossary_term'`

**Step 3: Implement the API method**

Add to `src/kb/api.py` after `list_glossary_terms()`:

```python
    def edit_glossary_term(self, term: str, expansion: str) -> dict[str, object]:
        """Update an existing glossary term's expansion. Raises ValueError if not found."""
        from kb.glossary import edit_term

        return edit_term(self._project_root, term, expansion)
```

**Step 4: Run API tests**

Run: `cd kbx && uv run pytest tests/test_api.py::TestGlossary -x -v`
Expected: All PASS

**Step 5: Add the CLI command**

Add to `src/kb/cli.py` after the `glossary_delete` function (after line ~1221):

```python
@glossary.command("edit")
@click.argument("term")
@click.argument("expansion")
@output_options
def glossary_edit(
    term: str, expansion: str, fmt: str, fields: list[str] | None, jq_expr: str | None
) -> None:
    """Update an existing glossary term's expansion."""
    from kb.glossary import edit_term

    try:
        result = edit_term(_find_project_root(), term, expansion)
    except ValueError as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from None
    kb_output(result, fmt=fmt, fields=fields, jq_expr=jq_expr)
```

**Step 6: Update `usage()` in `cli.py`**

In the `## 7. People & Projects` section (around line 1523), after `kb glossary add "TERM" "expansion"`, add:

```
  kb glossary edit "TERM" "new expansion"  # update existing term
```

And in the `## 10. Python API` section, after `kb.list_glossary_terms()`, add:

```
    kb.edit_glossary_term("TERM", "new expansion")  # -> dict
```

**Step 7: Run full test suite**

Run: `cd kbx && uv run pytest tests/ -x -q`
Expected: All PASS

**Step 8: Commit**

```bash
git add src/kb/api.py src/kb/cli.py tests/test_api.py
git commit -m "feat(glossary): add edit command at API + CLI levels"
```

---

## Task 3: Note Delete — Library Logic + CLI

Note delete doesn't need a separate library module — it's a CLI + API operation that removes a file and cleans the index. The logic lives directly in the CLI command and API method (matching the pattern of `note edit` which also lives in `cli.py`).

**Files:**
- Modify: `src/kb/cli.py` (add `note delete` command after `note_edit`, ~line 2130; update `usage()`)
- Test: `tests/test_cli.py`

**Step 1: Write the failing CLI test**

Add to `tests/test_cli.py`. First check how CLI tests are structured:

The CLI test file uses Click's `CliRunner`. Add a new test class:

```python
class TestNoteDelete:
    def test_note_delete_removes_file_and_db(self, cli_env):
        runner, root, _db = cli_env
        # Create a note first
        result = runner.invoke(cli, ["memory", "add", "Test Note", "--body", "content"])
        assert result.exit_code == 0

        # Verify note exists in DB
        conn = _db.get_sqlite_conn()
        row = conn.execute("SELECT * FROM documents WHERE title = 'Test Note'").fetchone()
        assert row is not None
        note_path = root / row["path"]
        assert note_path.exists()

        # Delete it
        result = runner.invoke(cli, ["note", "delete", "Test Note"])
        assert result.exit_code == 0

        # Verify file is gone
        assert not note_path.exists()
        # Verify DB is clean
        row = conn.execute("SELECT * FROM documents WHERE title = 'Test Note'").fetchone()
        assert row is None

    def test_note_delete_not_found(self, cli_env):
        runner, _root, _db = cli_env
        result = runner.invoke(cli, ["note", "delete", "Nonexistent"])
        assert result.exit_code != 0

    def test_note_delete_refuses_non_note(self, cli_env):
        runner, root, _db = cli_env
        # Insert a meeting doc directly in DB
        conn = _db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO documents (path, title, doc_date, doc_type, source_system,"
            " source_id, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
            ("meetings/standup.md", "Standup", "2026-02-20", "meeting", "granola", "abc", "hash1"),
        )
        conn.commit()
        result = runner.invoke(cli, ["note", "delete", "Standup"])
        assert result.exit_code != 0
        assert "Not a memory note" in result.output or "Not a memory note" in (result.stderr or "")
```

**Step 2: Run tests to verify they fail**

Run: `cd kbx && uv run pytest tests/test_cli.py::TestNoteDelete -x -v`
Expected: FAIL — `No such command 'delete'` or similar

**Step 3: Check the cli_env fixture**

Before implementing, read `tests/test_cli.py` to understand the `cli_env` fixture pattern and adapt accordingly. The test may need to use a different fixture or import pattern — adjust to match the existing test file's conventions.

**Step 4: Implement the CLI command**

Add to `src/kb/cli.py` after `note_edit` (after line ~2130):

```python
@note.command("delete")
@click.argument("target")
@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
@output_options
def note_delete(
    target: str,
    force: bool,
    fmt: str,
    fields: list[str] | None,
    jq_expr: str | None,
) -> None:
    """Delete a memory note (file + index entry)."""
    import os

    db = _get_db()
    conn = db.get_sqlite_conn()
    project_root = _find_project_root()

    # Resolve target
    doc = _find_document_by_target(conn, target)
    if doc is None:
        click.echo(f"Note not found: {target}", err=True)
        raise SystemExit(1)

    # Only allow deleting memory notes
    if doc["doc_type"] not in ("memory_note", "memory_doc"):
        click.echo(
            f"Not a memory note (doc_type={doc['doc_type']}). "
            "Only memory notes can be deleted with this command.",
            err=True,
        )
        raise SystemExit(1)

    if not force:
        click.confirm(f"Delete note '{doc['title']}'?", abort=True)

    doc_id = doc["id"]
    rel_path = doc["path"]

    # Delete file from disk
    file_path = project_root / rel_path
    if file_path.exists():
        file_path.unlink()

    # Clean DB: chunks, entity_mentions, FTS, documents
    conn.execute("DELETE FROM chunks WHERE document_id = ?", (doc_id,))
    conn.execute("DELETE FROM entity_mentions WHERE document_id = ?", (doc_id,))
    # FTS5 content table cleanup
    try:
        conn.execute("DELETE FROM chunks_fts WHERE rowid IN "
                      "(SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?)",
                      (str(doc_id),))
    except Exception:
        pass  # FTS cleanup is best-effort
    conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
    conn.commit()

    result_data: dict[str, Any] = {
        "path": rel_path,
        "title": doc["title"],
        "deleted": True,
    }
    if fmt == "table" and not fields and not jq_expr:
        click.echo(f"Deleted: {rel_path}", err=True)
    else:
        kb_output(result_data, fmt=fmt, fields=fields, jq_expr=jq_expr)
```

**Important note on FTS cleanup:** The FTS5 `chunks_fts` table is a content-sync table. Deleting from `chunks` may auto-cascade depending on how FTS was configured. Check `db.py` for the FTS5 CREATE TABLE to determine if `content=chunks` uses `content_rowid`. If it does, the FTS rows are cleaned up when `chunks` rows are deleted, so the explicit FTS DELETE above can be simplified. Verify during implementation by reading the FTS5 table definition in `db.py`.

**Step 5: Run tests**

Run: `cd kbx && uv run pytest tests/test_cli.py::TestNoteDelete -x -v`
Expected: All PASS. If the `cli_env` fixture doesn't exist, adapt the tests to use the existing CLI test patterns in that file.

**Step 6: Update `usage()` in `cli.py`**

In `## 5. Browsing & Editing Notes`, add after the `kb note edit` lines:

```
  kb note delete "title"               # delete note (file + index)
  kb note delete "#hash" --force       # skip confirmation
```

**Step 7: Commit**

```bash
git add src/kb/cli.py tests/test_cli.py
git commit -m "feat(notes): add note delete command"
```

---

## Task 4: Note Delete — Python API

**Files:**
- Modify: `src/kb/api.py` (add `delete_note()` method)
- Test: `tests/test_api.py`

**Step 1: Write the failing API test**

Add to `tests/test_api.py`:

```python
class TestNoteDelete:
    def test_delete_note(self, kb_instance, project_root):
        """delete_note removes file + DB records."""
        # Create a note file and index it
        notes_dir = project_root / "memory" / "notes"
        notes_dir.mkdir(parents=True, exist_ok=True)
        (notes_dir / "2026-02-28-test.md").write_text(
            "---\ntitle: Test Note\ndate: 2026-02-28\n---\nBody here\n"
        )
        kb_instance.index()

        # Verify it was indexed
        assert kb_instance.count_documents() >= 1

        result = kb_instance.delete_note("memory/notes/2026-02-28-test.md")
        assert result["deleted"] is True
        assert not (notes_dir / "2026-02-28-test.md").exists()

    def test_delete_note_not_found(self, kb_instance):
        with pytest.raises(ValueError, match="not found"):
            kb_instance.delete_note("memory/notes/nonexistent.md")

    def test_delete_note_refuses_non_note(self, kb_with_entities):
        """Cannot delete meeting docs via delete_note."""
        with pytest.raises(ValueError, match="Not a memory note"):
            kb_with_entities.delete_note("meetings/standup.md")
```

**Step 2: Run test to verify it fails**

Run: `cd kbx && uv run pytest tests/test_api.py::TestNoteDelete -x -v`
Expected: FAIL — `AttributeError: 'KnowledgeBase' object has no attribute 'delete_note'`

**Step 3: Implement the API method**

Add to `src/kb/api.py` in the "Memory file operations" section:

```python
    def delete_note(self, path: str) -> dict[str, object]:
        """Delete a memory note by path. Removes file + all DB records.

        Raises ValueError if the document is not found or is not a memory note.
        """
        conn = self._get_conn()
        row = conn.execute("SELECT * FROM documents WHERE path = ?", (path,)).fetchone()
        if row is None:
            raise ValueError(f"Note not found: {path}")

        if row["doc_type"] not in ("memory_note", "memory_doc"):
            raise ValueError(
                f"Not a memory note (doc_type={row['doc_type']}). "
                "Only memory notes can be deleted."
            )

        doc_id = row["id"]

        # Delete file from disk
        file_path = self._project_root / path
        if file_path.exists():
            file_path.unlink()

        # Clean DB
        conn.execute("DELETE FROM chunks WHERE document_id = ?", (doc_id,))
        conn.execute("DELETE FROM entity_mentions WHERE document_id = ?", (doc_id,))
        conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
        conn.commit()

        return {"path": path, "title": row["title"], "deleted": True}
```

**Step 4: Run tests**

Run: `cd kbx && uv run pytest tests/test_api.py::TestNoteDelete -x -v`
Expected: All PASS

**Step 5: Update `usage()` Python API section**

In `## 10. Python API`, add after `kb.list_memory_tree()`:

```
    kb.delete_note("notes/2026-02-28-test.md")    # -> dict (removes file + index)
```

**Step 6: Commit**

```bash
git add src/kb/api.py tests/test_api.py src/kb/cli.py
git commit -m "feat(notes): add delete_note() Python API method"
```

---

## Task 5: Fact Delete — Library + API + CLI

Facts live in two places: the `facts` SQLite table and the entity's markdown file under `## Recent Facts`. Both must be cleaned up on delete.

**Files:**
- Modify: `src/kb/api.py` (add `delete_fact()` method)
- Modify: `src/kb/cli.py` (add `memory delete-fact` command; update `usage()`)
- Test: `tests/test_api.py`, `tests/test_cli.py`

**Step 1: Write the failing API test**

Add to `tests/test_api.py`:

```python
class TestFactDelete:
    def test_delete_fact_by_id(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Joined Platform team', '2025-06-01')"
        )
        conn.commit()

        # Get the fact ID
        fact_row = conn.execute(
            "SELECT id FROM facts WHERE fact_text = 'Promoted to Lead'"
        ).fetchone()
        fact_id = fact_row["id"]

        result = kb_with_entities.delete_fact(fact_id)
        assert result["deleted"] is True
        assert result["fact_text"] == "Promoted to Lead"

        # Verify fact is gone from DB
        remaining = conn.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
        assert remaining is None

        # Other fact still exists
        other = conn.execute(
            "SELECT * FROM facts WHERE fact_text = 'Joined Platform team'"
        ).fetchone()
        assert other is not None

    def test_delete_fact_not_found(self, kb_instance):
        with pytest.raises(ValueError, match="Fact not found"):
            kb_instance.delete_fact(99999)

    def test_delete_fact_removes_from_entity_file(self, kb_with_entities, project_root):
        """If entity has a source file with ## Recent Facts, the line is removed."""
        # Create entity file with facts
        people_dir = project_root / "memory" / "people"
        people_dir.mkdir(parents=True, exist_ok=True)
        (people_dir / "eve.md").write_text(
            "# Eve Perrin\n\n**Role:** Engineering Leader\n\n"
            "## Recent Facts\n"
            "- [2026-01-15] Promoted to Lead\n"
            "- [2025-06-01] Joined Platform team\n"
        )

        conn = kb_with_entities._db.get_sqlite_conn()
        # Update source_path to point to our file
        conn.execute(
            "UPDATE entities SET source_path = 'memory/people/eve.md' WHERE id = 1"
        )
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.commit()

        fact_row = conn.execute(
            "SELECT id FROM facts WHERE fact_text = 'Promoted to Lead'"
        ).fetchone()

        kb_with_entities.delete_fact(fact_row["id"])

        content = (people_dir / "eve.md").read_text()
        assert "Promoted to Lead" not in content
        assert "Joined Platform team" in content
```

**Step 2: Run test to verify it fails**

Run: `cd kbx && uv run pytest tests/test_api.py::TestFactDelete -x -v`
Expected: FAIL — `AttributeError: 'KnowledgeBase' object has no attribute 'delete_fact'`

**Step 3: Implement the API method**

Add to `src/kb/api.py` in a new "Fact operations" section after the Entity section:

```python
    # ------------------------------------------------------------------
    # Fact operations
    # ------------------------------------------------------------------

    def delete_fact(self, fact_id: int) -> dict[str, object]:
        """Delete a fact by ID. Removes from DB and entity markdown file.

        Raises ValueError if the fact is not found.
        """
        import re as _re

        conn = self._get_conn()
        row = conn.execute(
            "SELECT f.id, f.entity_id, f.fact_text, f.fact_date, e.source_path "
            "FROM facts f JOIN entities e ON f.entity_id = e.id "
            "WHERE f.id = ?",
            (fact_id,),
        ).fetchone()
        if row is None:
            raise ValueError(f"Fact not found: {fact_id}")

        fact_text = row["fact_text"]
        fact_date = row["fact_date"]
        source_path = row["source_path"]

        # Remove from DB
        conn.execute("DELETE FROM facts WHERE id = ?", (fact_id,))
        conn.commit()

        # Remove from entity markdown file if present
        if source_path:
            file_path = self._project_root / source_path
            if file_path.exists():
                content = file_path.read_text(encoding="utf-8")
                # Match the fact line: - [date] text
                pattern = rf"^- \[{_re.escape(fact_date or '')}\] {_re.escape(fact_text)}\s*$\n?"
                new_content = _re.sub(pattern, "", content, count=1, flags=_re.MULTILINE)
                if new_content != content:
                    import os
                    import tempfile

                    fd, tmp = tempfile.mkstemp(
                        dir=str(file_path.parent), suffix=".md.tmp"
                    )
                    try:
                        with os.fdopen(fd, "w", encoding="utf-8") as f:
                            f.write(new_content)
                        os.replace(tmp, str(file_path))
                    except BaseException:
                        import contextlib

                        with contextlib.suppress(OSError):
                            os.unlink(tmp)
                        raise

        return {"fact_id": fact_id, "fact_text": fact_text, "deleted": True}
```

**Step 4: Run tests**

Run: `cd kbx && uv run pytest tests/test_api.py::TestFactDelete -x -v`
Expected: All PASS

**Step 5: Add CLI command**

Add to `src/kb/cli.py` after `memory_list` (the `memory list` command):

```python
@memory.command("delete-fact")
@click.argument("fact_id", type=int)
@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
@output_options
def memory_delete_fact(
    fact_id: int,
    force: bool,
    fmt: str,
    fields: list[str] | None,
    jq_expr: str | None,
) -> None:
    """Delete a fact by ID."""
    from kb.api import KnowledgeBase
    from kb.config import find_project_root, get_data_dir

    kb = KnowledgeBase(project_root=find_project_root(), data_dir=get_data_dir())
    try:
        # Show the fact first
        conn = kb._get_conn()
        row = conn.execute(
            "SELECT f.fact_text, f.fact_date, e.name "
            "FROM facts f JOIN entities e ON f.entity_id = e.id "
            "WHERE f.id = ?",
            (fact_id,),
        ).fetchone()
        if row is None:
            click.echo(f"Fact not found: {fact_id}", err=True)
            raise SystemExit(1)

        if not force:
            click.confirm(
                f"Delete fact [{row['fact_date']}] \"{row['fact_text']}\" "
                f"(entity: {row['name']})?",
                abort=True,
            )

        result = kb.delete_fact(fact_id)
        if fmt == "table" and not fields and not jq_expr:
            click.echo(f"Deleted fact {fact_id}.", err=True)
        else:
            kb_output(result, fmt=fmt, fields=fields, jq_expr=jq_expr)
    finally:
        kb.close()
```

**Step 6: Update `usage()` in `cli.py`**

In `## 3. Taking Notes`, add:

```
  kb memory delete-fact <id>             # delete a fact by ID
  kb memory delete-fact <id> --force     # skip confirmation
```

And in `## 10. Python API`, add:

```
    kb.delete_fact(fact_id)                        # -> dict (removes from DB + file)
```

**Step 7: Run full test suite**

Run: `cd kbx && uv run pytest tests/ -x -q`
Expected: All PASS

**Step 8: Commit**

```bash
git add src/kb/api.py src/kb/cli.py tests/test_api.py tests/test_cli.py
git commit -m "feat(facts): add fact delete at API + CLI levels"
```

---

## Task 6: Fact Edit — API + CLI

Fact edit updates the `fact_text` and/or `fact_date` of an existing fact. Like delete, it must sync both the DB and the entity markdown file.

**Files:**
- Modify: `src/kb/api.py` (add `edit_fact()` method)
- Modify: `src/kb/cli.py` (add `memory edit-fact` command; update `usage()`)
- Test: `tests/test_api.py`, `tests/test_cli.py`

**Step 1: Write the failing API test**

Add to `tests/test_api.py`:

```python
class TestFactEdit:
    def test_edit_fact_text(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.commit()

        fact_row = conn.execute(
            "SELECT id FROM facts WHERE fact_text = 'Promoted to Lead'"
        ).fetchone()

        result = kb_with_entities.edit_fact(fact_row["id"], text="Promoted to Staff")
        assert result["fact_text"] == "Promoted to Staff"

        # Verify DB
        updated = conn.execute("SELECT * FROM facts WHERE id = ?", (fact_row["id"],)).fetchone()
        assert updated["fact_text"] == "Promoted to Staff"

    def test_edit_fact_date(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.commit()
        fact_row = conn.execute(
            "SELECT id FROM facts WHERE fact_text = 'Promoted to Lead'"
        ).fetchone()

        result = kb_with_entities.edit_fact(fact_row["id"], date="2026-02-01")
        assert result["fact_date"] == "2026-02-01"

    def test_edit_fact_not_found(self, kb_instance):
        with pytest.raises(ValueError, match="Fact not found"):
            kb_instance.edit_fact(99999, text="new text")

    def test_edit_fact_no_changes(self, kb_with_entities):
        """Calling edit_fact with no text or date raises."""
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Some fact', '2026-01-15')"
        )
        conn.commit()
        fact_row = conn.execute("SELECT id FROM facts WHERE fact_text = 'Some fact'").fetchone()
        with pytest.raises(ValueError, match="No changes"):
            kb_with_entities.edit_fact(fact_row["id"])

    def test_edit_fact_updates_entity_file(self, kb_with_entities, project_root):
        """If entity has a source file, the fact line is updated."""
        people_dir = project_root / "memory" / "people"
        people_dir.mkdir(parents=True, exist_ok=True)
        (people_dir / "eve.md").write_text(
            "# Eve Perrin\n\n**Role:** Engineering Leader\n\n"
            "## Recent Facts\n"
            "- [2026-01-15] Promoted to Lead\n"
        )

        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "UPDATE entities SET source_path = 'memory/people/eve.md' WHERE id = 1"
        )
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.commit()

        fact_row = conn.execute(
            "SELECT id FROM facts WHERE fact_text = 'Promoted to Lead'"
        ).fetchone()

        kb_with_entities.edit_fact(fact_row["id"], text="Promoted to Staff")

        content = (people_dir / "eve.md").read_text()
        assert "Promoted to Staff" in content
        assert "Promoted to Lead" not in content
```

**Step 2: Run test to verify it fails**

Run: `cd kbx && uv run pytest tests/test_api.py::TestFactEdit -x -v`
Expected: FAIL — `AttributeError: 'KnowledgeBase' object has no attribute 'edit_fact'`

**Step 3: Implement the API method**

Add to `src/kb/api.py` after `delete_fact()`:

```python
    def edit_fact(
        self,
        fact_id: int,
        *,
        text: str | None = None,
        date: str | None = None,
    ) -> dict[str, object]:
        """Edit a fact's text and/or date. Syncs DB and entity markdown file.

        Raises ValueError if fact not found or no changes specified.
        """
        import re as _re

        if text is None and date is None:
            raise ValueError("No changes specified. Provide text and/or date.")

        conn = self._get_conn()
        row = conn.execute(
            "SELECT f.id, f.entity_id, f.fact_text, f.fact_date, e.source_path "
            "FROM facts f JOIN entities e ON f.entity_id = e.id "
            "WHERE f.id = ?",
            (fact_id,),
        ).fetchone()
        if row is None:
            raise ValueError(f"Fact not found: {fact_id}")

        old_text = row["fact_text"]
        old_date = row["fact_date"]
        new_text = text if text is not None else old_text
        new_date = date if date is not None else old_date
        source_path = row["source_path"]

        # Update DB
        conn.execute(
            "UPDATE facts SET fact_text = ?, fact_date = ? WHERE id = ?",
            (new_text, new_date, fact_id),
        )
        conn.commit()

        # Update entity markdown file if present
        if source_path:
            file_path = self._project_root / source_path
            if file_path.exists():
                content = file_path.read_text(encoding="utf-8")
                old_line = f"- [{old_date or ''}] {old_text}"
                new_line = f"- [{new_date or ''}] {new_text}"
                pattern = rf"^- \[{_re.escape(old_date or '')}\] {_re.escape(old_text)}\s*$"
                new_content = _re.sub(
                    pattern, new_line, content, count=1, flags=_re.MULTILINE
                )
                if new_content != content:
                    import os
                    import tempfile

                    fd, tmp = tempfile.mkstemp(
                        dir=str(file_path.parent), suffix=".md.tmp"
                    )
                    try:
                        with os.fdopen(fd, "w", encoding="utf-8") as f:
                            f.write(new_content)
                        os.replace(tmp, str(file_path))
                    except BaseException:
                        import contextlib

                        with contextlib.suppress(OSError):
                            os.unlink(tmp)
                        raise

        return {
            "fact_id": fact_id,
            "fact_text": new_text,
            "fact_date": new_date,
            "updated": True,
        }
```

**Step 4: Run tests**

Run: `cd kbx && uv run pytest tests/test_api.py::TestFactEdit -x -v`
Expected: All PASS

**Step 5: Add CLI command**

Add to `src/kb/cli.py` after `memory_delete_fact`:

```python
@memory.command("edit-fact")
@click.argument("fact_id", type=int)
@click.option("--text", default=None, help="New fact text.")
@click.option("--date", "fact_date", default=None, help="New fact date (YYYY-MM-DD).")
@output_options
def memory_edit_fact(
    fact_id: int,
    text: str | None,
    fact_date: str | None,
    fmt: str,
    fields: list[str] | None,
    jq_expr: str | None,
) -> None:
    """Edit a fact's text or date."""
    from kb.api import KnowledgeBase
    from kb.config import find_project_root, get_data_dir

    if text is None and fact_date is None:
        click.echo("Specify --text and/or --date.", err=True)
        raise SystemExit(1)

    kb = KnowledgeBase(project_root=find_project_root(), data_dir=get_data_dir())
    try:
        result = kb.edit_fact(fact_id, text=text, date=fact_date)
        if fmt == "table" and not fields and not jq_expr:
            click.echo(f"Updated fact {fact_id}.", err=True)
        else:
            kb_output(result, fmt=fmt, fields=fields, jq_expr=jq_expr)
    except ValueError as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from None
    finally:
        kb.close()
```

**Step 6: Update `usage()` in `cli.py`**

In `## 3. Taking Notes`, add alongside the delete-fact line:

```
  kb memory edit-fact <id> --text "new text"    # update fact text
  kb memory edit-fact <id> --date 2026-03-01    # update fact date
```

And in `## 10. Python API`, add:

```
    kb.edit_fact(fact_id, text="new text")          # -> dict (updates DB + file)
```

**Step 7: Run full test suite**

Run: `cd kbx && uv run pytest tests/ -x -q`
Expected: All PASS

**Step 8: Commit**

```bash
git add src/kb/api.py src/kb/cli.py tests/test_api.py tests/test_cli.py
git commit -m "feat(facts): add fact edit at API + CLI levels"
```

---

## Task 7: Final Verification + Type Checking

**Step 1: Run full test suite**

Run: `cd kbx && uv run pytest tests/ -x -q --cov`
Expected: All PASS, coverage >= 90%

**Step 2: Run mypy**

Run: `cd kbx && uv run mypy src/`
Expected: Clean — no errors

**Step 3: Run ruff**

Run: `cd kbx && uv run ruff check src/ && uv run ruff format --check src/`
Expected: Clean

**Step 4: Verify usage() output**

Run: `cd kbx && uv run kb usage`
Verify the new commands appear in the right sections.

**Step 5: Final commit if any lint/type fixes were needed**

```bash
git add -u
git commit -m "chore: fix lint/type issues from CRUD gap implementation"
```
