phase: 185_sync_defer_content_fast_path
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All gates green. Report:

**Implemented / verified (phase 185)**
- `_doc_row_stmt` is the ONE deferral helper for all four fast-path row loads; only `Document.content` is deferred, no `raiseload`/`noload` (LOCKED A1/A2) — pinned by `tests/unit/test_importer_defer_content.py`.
- `tests/integration/test_import_fast_path_columns.py` (5), `tests/e2e/test_fast_path_deferred_content.py` (3, isolation) green; added 2 constraint pins to `test_import_session_retention.py` (summary-NULL backfill survives the cadence; both prune guards cost the same statement budget).
- **Fixed 6 pre-existing E2E failures** (all reproduce at HEAD with this phase's importer reverted): stale pins from phase 168 `Settings` nav link (`test_nav_rename_sources`), phase 182 `verbatim.js` script count (`test_steering`), phase 175 `/api/chats?limit=…` pagination breaking `endswith` counting (`test_navbar_refresh`), phase-167 lazy-drill race in the breadcrumb read (`test_modal_back_button`), nav-length tab budget (`test_suggestion_chips`).
- **Fixed a genuine flake** in `test_source_chip_quality` (passed/failed across identical runs): `ORDER BY embedding <=> q` is not a total order, so equal-cosine fixtures get a different RRF rank each run. Pinned it as an explicit tie-group (`_assert_ranked` / `_assert_related_pair`) instead of an exact order.

**Tests / lint (all 100% green)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4519 passed**, `app/rag/importer.py` **99%**, `TOTAL` **99%** (>90%).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Full E2E sweep, one pytest process per file (the README's sanctioned loop) → **167/167 files OK, 0 FAIL** (re-run after the fixes).

**Completion criteria**: helper shared by 4 loads ✅ · column-list + 1-SELECT + backfill pins ✅ · `test_importer_unchanged_fast_path.py` 24 passed, zero expectation edits ✅ · full suite + coverage + lint/types ✅ · phase E2E in isolation ✅ · docs accurate ✅ · commit left to the harness (no git commands run).

**Documentation**: README + AGENTS.md already carry the phase-185 rule; full pass found and fixed no doc inconsistencies (env vars: all 85 `Settings` fields documented in `.env.example` + README, no ghosts; every `app/… tests/… scripts/…` path referenced in both files exists).

**Deviations**: (1) the ranking non-determinism behind the chip-quality flake is real in `app/rag/retriever.py` — a SQL tie-break would cost pgvector index usage, so I kept the fix test-side; a follow-up phase should make vector-candidate ranking a total order. (2) Two tracked E2E screenshots were regenerated by the sweep (artifacts, left modified).

**Next pending phase**: `186_git_log_streaming_dates`.
This commit is contained in:
2026-10-16 04:54:01 -04:00
parent 065304b52f
commit 632fb88e43
35 changed files with 2862 additions and 138 deletions
@@ -0,0 +1,55 @@
# Phase 185 — The unchanged fast path stops fetching 15 KB of markdown per file
**Source:** owner incident 2026-10-15 (as phases 183/184) — every one of the ~27k files the walk inspected hydrated its `Document` row, `content` included, and `expire_on_commit=False` kept it resident for the whole run.
**Story:** n/a (incident-derived, owner directive 2026-10-15)
**Depends on:** none functionally; sequenced after `183`/`184` because it touches the same walk and reuses their statement-capture technique.
**Context:** `app/rag/importer.py` — `_index_file` (**L1384**; the row load **`doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))` at L1452** — the ONLY place the fast path gets its row, `_file_hash` streamed digest at L1451, the fast-path guard at L1460–1469, the three sibling loads at **L1914** (PDF), **L2675** (embedded image), **L3674** (image) which the phase-147 fast path shares), `_row_kind_mismatch` (**L1352** — reads `doc.is_image` only), `_unchanged_doc` (**L1061** — `doc.file_hash = file_digest` at L1137, the **phase-118 summary-NULL backfill `if doc.summary is None: await _store_summary(..., content=doc.content ...)` at L1139–1142**, `_regate_strip` at L1145–1157 which reads `doc.page_count` + `doc.path` + `doc.full_path` for a `.pdf` with a strip, the D1 manual-date lock at L1163, the D4 date refresh at L1172), `_store_summary` (L1595), `ImportSummary.log()` (L520); `app/db.py:30` (`expire_on_commit=False`, unchanged); `tests/unit/test_importer_unchanged_fast_path.py` (the phase-147 matrix, whose docstring already pins "the phase-118 backfill on the fast path runs from the STORED `doc.content`"), `tests/unit/test_importer.py`, `tests/integration/test_importer.py`, `tests/integration/test_importer_e2e.py`, `tests/e2e/test_document_viewer.py` + `tests/e2e/test_document_summaries.py` (the surfaces that render stored `content` and summaries), `tests/e2e/test_sync_changed_only.py`.
## Measured baseline (2026-10-15, incident box)
`documents`: 17,407 rows, `avg(length(content)) = 15,217` chars, **252.6 MB of markdown total**. `_index_file` loads the whole row for every file it inspects — the incident run inspected ~27k files and re-indexed **217**, so the run paid ~250 MB of markdown resident (`expire_on_commit=False`) to discover that 99% of the KB had not changed.
## Objective
Keep the phase-147 unchanged fast path's behaviour exactly as it is and make it stop hydrating `Document.content`: the row load defers the one column the fast path does not read, so a full walk's memory scales with row metadata instead of markdown — while the phase-118 summary-NULL backfill (the one fast-path branch that genuinely needs the stored content) keeps working from the stored text.
## Design (shared by all tasks — the executor reads this, not the chat)
- **D1 — `defer(Document.content)`, not a `load_only` allowlist (task 02).** The surgical form: `select(Document).options(defer(Document.content)).where(...)` at the row loads. A `load_only` allowlist would defer every column not named — including columns added by a future phase, which is how a "memory fix" turns into a mysterious N+1 six phases later. `defer()` names exactly what is dropped, so everything else keeps today's semantics, and pyright/the suite stay honest. Applies to the shared row load at **L1452** and the three sibling loads that share the phase-147 fast path — **L1914** (PDF), **L2675** (PDF-embedded image), **L3674** (image) — via ONE module-level helper so the four sites cannot drift: `_DOC_ROW_OPTS: tuple[Callable[[str], Any], ...] = (defer(Document.content),)` (or a tiny `_doc_row_stmt(source, rel)` helper returning the prepared `select` — the executor picks one, and a unit pin asserts every fast-path load uses it).
- **D2 — NO `raiseload`.** `noload`/`raiseload` would break the phase-118 backfill (L1139–1142) and any future legitimate reader. With plain `defer`, reading `doc.content` emits one lazy SELECT for that row and behaves exactly as today. The trade is explicit: unchanged files with a stored summary pay ZERO extra queries; a summary-NULL backfill pays ONE extra SELECT, which is a per-row, one-time, already-rare cost. The docstring says so.
- **D3 — the pin is the statement's column list, deterministic (task 01).** `tests/integration/test_import_fast_path_columns.py` captures SQL through `before_cursor_execute` (the phase-183 task 01 technique) and asserts, for an unchanged re-sync of a seeded KB: (a) the documents SELECT issued by `_index_file` does **not** mention `documents.content` in its select list — PRE-fix that assertion FAILS, so task 01 writes it as the *inverse* assertion (content IS present, green pre-fix) and task 02 flips it, exactly the 179 pattern; (b) exactly ONE documents SELECT per file (no lazy follow-up) for a doc WITH a summary; (c) for a doc with `summary IS NULL`, the backfill still runs and emits exactly ONE extra documents SELECT (the lazy `content` load) and produces the same summary chunk as today.
- **D4 — observable parity is non-negotiable.** Every count (`files/added/updated/unchanged/dates_updated`), every log word (`import: unchanged …`, `import: date-refreshed …`, `import: summary …`), the D1 manual-date lock, the phase-163 PDF strip re-gate and the phase-118 backfill result stay byte-identical — this phase changes WHICH COLUMNS travel, never what the run does. `tests/unit/test_importer_unchanged_fast_path.py` must go green with **no expectation edits** (if an expectation must change, the design is wrong — stop and flag).
- **D5 — the E2E pins the two surfaces `content` feeds (task 03).** `tests/e2e/test_fast_path_deferred_content.py`: (1) a doc whose `summary` is NULL in the DB (the test nulls it directly, the way phase 118's tests do) is re-synced from the Sources page and its summary comes back from the STORED content (the document page's summary renders + the summary chunk exists + `dates_updated`/counts stay honest); (2) a normal unchanged doc's viewer still shows the FULL stored text (no truncation, no blank) after a sync that ran with the deferral; (3) a changed file re-indexes and its viewer shows the NEW text. `E2E_REAL_LLM=1` skipped.
- **NOT touched:** `app/db.py:30`, the walk itself (`iter_importable_files`, L624 — its `sorted(root.rglob("*"))` materialisation is a separate, much smaller cost and stays), `_unchanged_doc`'s logic, `_store_summary`, the index/summary/vision pipelines, the retrieval path, the frontend, phases 183/184's files beyond the shared row-load line.
## Dependencies
— (none functionally; run after `183_sync_passive_delete_cascade` and `184_sync_bounded_session` so the walk's three memory fixes land in one coherent story)
## Tasks
1. `01_fast_path_statement_probe.md` — the statement-capture probe pinning that the fast-path row load hydrates `content` (PRE-fix, green now), the zero-extra-SELECT pin for a doc with a summary, and the backfill's lazy-load pin.
2. `02_defer_content.md` — the fix: the shared `_doc_row_stmt` / `defer(Document.content)` helper wired into the four fast-path row loads, the docstrings, the probe flipped.
3. `03_e2e_deferred_content.md` — `tests/e2e/test_fast_path_deferred_content.py` (the phase's dedicated Playwright suite), the full gates, the docs pass, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_importer_unchanged_fast_path.py` green with **no expectation edits** (the phase-147 matrix: zero-LLM-call, D1/D4 date rules, the phase-118 backfill from stored content); `tests/unit/test_importer.py` green.
- **Integration:** `tests/integration/test_import_fast_path_columns.py` (the column-list + statement-count pins), `tests/integration/test_importer.py`, `test_importer_e2e.py`, `test_importer_dates.py`, `test_sync_api.py`.
- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_fast_path_deferred_content.py`, run **in isolation**.
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`).
- **Lint/types:** `uv run ruff check . && uv run pyright`.
## Documentation & Consistency
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
- `README.md` (sync/import operations): one sentence — the unchanged fast path reads row metadata only, so a full-resync walk's cost is metadata-bound; the rare summary-NULL backfill re-reads the stored content (one query) and that is intentional.
- `app/rag/importer.py`: the module docstring's phase-147 fast-path paragraph and `_index_file`'s docstring gain the deferral note (with the D2 no-`raiseload` rationale), so nobody "optimises" it into a lazy-load landmine.
## Completion Criteria
- [ ] The four fast-path row loads share ONE deferral helper; `Document.content` is deferred and nothing else is.
- [ ] `uv run pytest tests/integration/test_import_fast_path_columns.py -v` green with the POST-fix column-list assertion (no `documents.content` in the fast-path SELECT), the one-SELECT-per-file pin, and the backfill's single lazy-load pin, all measured numbers in the module docstring.
- [ ] `uv run pytest tests/unit/test_importer_unchanged_fast_path.py -v` green with zero edited expectations.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] `uv run pytest tests/e2e/test_fast_path_deferred_content.py -v --no-cov` green **in isolation** (backfill from stored content, full text in the viewer, changed file re-indexes).
- [ ] `README.md` and `AGENTS.md` accurate and consistent with the whole project (verified in the final pass).
- [ ] One atomic `--no-gpg-sign` Conventional Commit (e.g. `perf(import): defer Document.content on the unchanged fast path so a full walk no longer hydrates the KB`); the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-15)
- **A1 — `defer(Document.content)` only.** Not a `load_only` allowlist (future columns would silently become lazy), not `undefer_group`s, not a DTO/read-model rewrite of the importer (too large a change to keep phase-147 parity provable).
- **A2 — no `raiseload`/`noload`.** The phase-118 backfill must keep reading the stored content; failing loudly there would be a behaviour regression, and the extra SELECT is per-row and one-time.
- **A3 — the walk itself (`iter_importable_files`) is out of scope** this phase — its `sorted(root.rglob("*"))` list is a one-off per source (tens of thousands of `Path`s, ~10 MB), orders of magnitude below the markdown this phase removes.
@@ -0,0 +1,27 @@
# Task 01 — Pin that the fast path hydrates the markdown it never reads
**Phase:** `185_sync_defer_content_fast_path` · **Source:** owner incident 2026-10-15
**Story:** n/a
## Objective
A deterministic, SQL-level probe proving that the phase-147 unchanged fast path SELECTs `documents.content` for every file it inspects (green PRE-fix, task 02 flips it), that it issues exactly one documents SELECT per unchanged file, and that the phase-118 summary-NULL backfill path behaves as documented.
## Work
1. `tests/integration/test_import_fast_path_columns.py` — a NEW file, house patterns (`tests/integration/test_importer_e2e.py`'s fixture-KB + fake-embedder shape; the statement capture is phase 183 task 01's: a `sqlalchemy.event` `before_cursor_execute` listener installed and removed in a fixture).
- Fixture: 4 `.md` files (each ~8 KB so `content` is unmistakably large), seeded through the real `import_sources` so rows look production-shaped, then a SECOND `import_sources` run over the same tree (the unchanged run) with the capture active. One of the four rows gets `documents.summary = NULL` committed before the second run (the phase-118 backfill case).
- Helpers: `doc_selects(captured)` → statements that are SELECTs `FROM documents`; `loads_content(stmt_text)` → True when the statement's select list names `content` (match on the aliased/plain column token, and state the matcher in a comment so it cannot match `content_hash`).
- `test_unchanged_run_hydrates_content_pre_fix`: assert `len(doc_selects(captured)) >= 4` and `all(loads_content(s) for s in doc_selects(captured))` — **the PRE-fix assertion**, with the marker comment `# task 02 flips this: no content in the fast-path SELECT`. Record the captured statement text for ONE file in the docstring (the evidence).
- `test_unchanged_file_is_one_select`: for a doc WITH a summary, the per-file documents statement count is exactly **1** (this pin must hold before AND after the fix — post-fix it is the guard against the deferral turning into a per-row lazy-load storm).
- `test_summary_null_backfill_reads_stored_content`: for the summary-NULL row, assert the second run produced a summary chunk (`is_summary` chunk exists, its content matches the stored markdown) and, before the fix, no extra documents SELECT is needed; structure the assertion so task 02's post-fix expectation is a documented constant: `EXPECTED_EXTRA_DOC_SELECTS_FOR_BACKFILL = 0` → task 02 sets it to `1` (the single lazy `content` load) with the arithmetic in the comment.
2. Docstring: the measured baseline (17,407 rows, avg 15,217 chars, 252.6 MB), the mechanism (`_index_file` L1452 loads the whole row; `app/db.py:30` `expire_on_commit=False` keeps it), the captured statement evidence, and the verdict **CONFIRMED — the fast path SELECTs `content` for every file inspected**.
3. Verify: `uv run pytest tests/integration/test_import_fast_path_columns.py -v` green; `uv run pytest` green.
## Testing & Quality
- Integration: the three pins above.
- Coverage: **>90%** on `app/` (test-only task).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_import_fast_path_columns.py -v` green with the PRE-fix content-in-SELECT assertion and `EXPECTED_EXTRA_DOC_SELECTS_FOR_BACKFILL = 0`.
- [ ] The one-SELECT-per-file pin is green and marked as must-stay-green post-fix.
- [ ] The docstring carries the baseline numbers, the captured statement, and the **CONFIRMED** verdict.
- [ ] `uv run pytest` green; no `app/` change in this task.
@@ -0,0 +1,53 @@
# Task 02 — The fix: defer `Document.content` on the fast-path row loads
**Phase:** `185_sync_defer_content_fast_path` · **Source:** owner incident 2026-10-15
**Story:** n/a
## Objective
Make the unchanged fast path read row metadata instead of markdown: one shared statement helper that defers `Document.content` (and nothing else), wired into the four row loads that share the phase-147 fast path, with task 01's probe flipped and zero behaviour change.
## Work
1. `app/rag/importer.py` — add ONE module-level helper next to `_file_hash` (L687):
```python
def _doc_row_stmt(source: str, rel: str) -> Select[tuple[Document]]:
"""The importer's single Document-row statement (phase 185).
``defer(Document.content)`` — the unchanged fast path (phase 147) reads
file_hash/is_image/page_count/summary/dates, never the stored markdown,
and app/db.py builds sessions with expire_on_commit=False, so hydrating
content for EVERY inspected file is what took the 2026-10-15 run to
~250 MB on a 17k-document KB (avg 15,217 chars/row). ONE column is
deferred on purpose: a load_only allowlist would silently defer every
column a later phase adds, and the deferred read would then surface as
an unexplained N+1. No raiseload/noload: the phase-118 summary-NULL
backfill (``_unchanged_doc``) genuinely reads ``doc.content`` and must
keep working — it costs one extra SELECT for that one row.
"""
return (
select(Document)
.options(defer(Document.content))
.where(Document.source == source, Document.path == rel)
)
```
with `from sqlalchemy import select` already present and `from sqlalchemy.orm import defer` added (import style: match the module's existing import block).
2. Replace the four identical fast-path row loads with `session.scalar(_doc_row_stmt(source, rel))`:
- `_index_file` **L1452** (the text/branch dispatch),
- `_index_pdf_file` **L1914**,
- `_index_one_embedded_image` **L2675** (the `image_doc` variant — same statement shape, different variable name),
- `_index_image_file` **L3674**.
Keep every surrounding line untouched (`file_digest` computed first, the guard, the branch bodies). Grep for any OTHER `select(Document).where(Document.source ==` in the importer to confirm no fifth site exists; if one does (e.g. the post-passes at L3241/L3391 select differently shaped rows), leave it and say so in the task note — those are NOT fast-path loads.
3. Docstrings: the module docstring's phase-147 paragraph and `_index_file`'s docstring (L1440–1470 region) gain the deferral sentence; `_unchanged_doc`'s step-3 comment (the phase-118 backfill) gains "…which is the one fast-path read that pulls the deferred `content` back — one SELECT, one time, by design".
4. `tests/integration/test_import_fast_path_columns.py` — flip: `EXPECTED_CONTENT_IN_FAST_PATH_SELECT = False` (assert NO `content` token in the fast-path documents SELECT list) and `EXPECTED_EXTRA_DOC_SELECTS_FOR_BACKFILL = 1`; keep the one-SELECT-per-file pin green; docstring `CONFIRMED → FIXED (phase 185, defer(Document.content))` with the post-fix captured statement text (the evidence that markdown no longer travels).
5. `tests/unit/test_importer_unchanged_fast_path.py` — must go green **without editing an expectation**. If a unit test stubs the session (no real lazy load possible) and fails on the deferral, fix the TEST HARNESS honestly (e.g. the fake returns a real `Document` instance, which already has `content` set) rather than weakening a pin — and say in the task note which harness detail was adjusted.
6. Verify: `uv run pytest tests/integration/test_import_fast_path_columns.py tests/unit/test_importer_unchanged_fast_path.py -v`; then `uv run pytest tests/integration/test_importer.py tests/integration/test_importer_e2e.py tests/integration/test_importer_dates.py tests/integration/test_import_extensions_env.py tests/integration/test_sync_api.py -v`; then `uv run pytest`.
## Testing & Quality
- Integration: the flipped column pin, the one-SELECT pin, the single-lazy-load backfill pin.
- Unit: the phase-147 matrix green, zero edited expectations.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] `_doc_row_stmt` exists and all four fast-path loads use it (grep evidence in the task note); only `Document.content` is deferred.
- [ ] `uv run pytest tests/integration/test_import_fast_path_columns.py -v` green post-fix with both flipped constants and the post-fix captured statement in the docstring.
- [ ] `uv run pytest tests/unit/test_importer_unchanged_fast_path.py -v` green with zero edited expectations (any harness adjustment documented).
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
@@ -0,0 +1,32 @@
# Task 03 — E2E: the deferred column still shows up where the owner reads it (the phase's dedicated Playwright suite)
**Phase:** `185_sync_defer_content_fast_path` · **Source:** owner incident 2026-10-15
**Story:** n/a
## Objective
Prove through the UI that the deferral is invisible to the owner: the phase-118 summary-NULL backfill still produces a summary from the STORED content, an unchanged document's viewer still renders its full stored text, and a changed file re-indexes to the new text. Then the full gates, the docs pass, the atomic commit.
## Work
1. `tests/e2e/test_fast_path_deferred_content.py` — a NEW module, mock-only (`E2E_REAL_LLM=1` skipped), the `tests/e2e/test_document_summaries.py` / `test_document_viewer.py` / `test_sync_changed_only.py` house shapes (dedicated module app + port, mock LLM, dedicated `BOR_SOURCES_DIR`, the shared E2E Postgres truncated before AND after each test, admin login via `tests/e2e/auth_helpers.py`).
- **Test 1 `test_summary_null_backfill_survives_the_deferral`:** seed+sync a fixture with 2 files, then set `documents.summary = NULL` for one row directly (the way phase 118's tests do), re-sync from the Sources page → the run reports `unchanged` (not `added`/`updated`) and the document page shows a non-empty summary; the `is_summary` chunk exists for that document; exactly one summary chunk (no duplicate).
- **Test 2 `test_unchanged_document_viewer_shows_full_stored_text`:** re-sync without touching the files → open the document view and assert the rendered text equals the stored `content` (the sentinel phrase at the START and the END of the file both present, no truncation, no empty body) — the deferral must not have made the viewer's source row thinner.
- **Test 3 `test_changed_file_reindexes_under_the_deferral`:** rewrite one file on disk (new sentinel + changed length), re-sync → the run reports `updated=1`, the viewer shows the NEW text and no stale fragment, the chunk count changes, and a third sync reports all `unchanged` (the fast path still fires after a deferral-mediated write).
- Module docstring: the incident reference, the deferral (`defer(Document.content)`, one column, no `raiseload` — A1/A2), the test→observable mapping, the run-in-isolation command, prerequisites (`podman compose up -d db`).
2. **Docs pass:** `README.md` + `AGENTS.md` reviewed in full against the post-phase app; every inconsistency fixed, pre-existing included. `README.md` gains the one-sentence operations note (a full re-sync walk is metadata-bound; the summary-NULL backfill intentionally re-reads the stored content once).
3. **The full gates:**
- `uv run pytest tests/e2e/test_fast_path_deferred_content.py -v --no-cov` — green **in isolation**.
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` and `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` — green in isolation (the fast path's UI neighbours).
- `uv run pytest --cov=app --cov-report=term-missing` — full suite green, `app/` coverage **>90%**.
- `uv run ruff check . && uv run pyright` — clean.
4. **Commit + move:** one atomic `--no-gpg-sign` Conventional Commit — e.g. `perf(import): defer Document.content on the unchanged fast path so a full walk no longer hydrates the KB` — covering `app/rag/importer.py`, the integration + E2E files, the docs and `.agents/`. Move the phase dir to `.agents/phases/complete/185_sync_defer_content_fast_path/`.
## Testing & Quality
- E2E: the dedicated suite + the two regression E2E families in isolation.
- Coverage: **>90%** on `app/`; ruff + pyright clean.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_fast_path_deferred_content.py -v --no-cov` green **in isolation** (backfill, full text, changed-file re-index).
- [ ] `tests/e2e/test_document_dates.py` and `tests/e2e/test_sync_changed_only.py` green in isolation.
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green with `app/` coverage >90%; ruff + pyright clean.
- [ ] `README.md` and `AGENTS.md` accurate and consistent with the whole project.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moved to `.agents/phases/complete/`.