**Phase 194 — final verification pass: all green.**
**Verified (all 5 tasks already complete; nothing re-implemented needed)**
- `app/rag/importer.py`: `_prescan_pending` + `_pending_hash_stmt` (scalar `documents.file_hash`, own session, ONE `asyncio.to_thread` span, REPLACES the stats pre-walk), `on_scan_progress`, `progress` pair = pending position, `0` branch = byte-identical phase-193 walk.
- `app/config.py`/`settings_catalog.py`/`.env.example`: `sync_pending_count=1` + validator; `app/api/sync.py`: `"scan"` phase, `scan_done`/`scan_total` (additive after `files_total`, kept at every terminal), forced/throttled RSS split; `frontend/assets/sources.js`: `fmtSyncPhaseLabel` scan branch first-arm, `fmtSyncLabel` untouched.
**Tests / lint / coverage (exact commands)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4835 passed**, `app/` 99% (7240 stmts / 16 miss), exit 0
- `uv run pytest tests/e2e/test_sync_pending_count.py -v --no-cov` → **8 passed in 150.81s** (phase suite, isolated)
- 14 sync-lineage E2E suites, one pytest process each (`test_sync_remaining_count`, `…_chunk_progress`, `…_progress_box`, `…_peak_memory_visible`, `…_label_mobile`, `…_upload_progress`, `test_archive_upload_sources`, `…_image_progress`, `…_summary_visibility`, `…_status_freshness`, `test_smoke`, `test_fast_path_deferred_content`, `test_sync_session_reset`, `test_sync_git_freeze`) → **all exit 0**
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
**Completion criteria** — all met: `N remaining` = predicted change set (E2E arms 1/2/5, first import counts the walk, no-change → no count part); `scan` phase visible + flips to `import`, scan pair reset/kept at terminals (`test_sync_scan_phase.py`); `BOR_SYNC_PENDING_COUNT=0` walk semantics ("7 remaining" arm) + **negative refuses startup naming the field (verified live by importing `app.main`)**; pre-scan off-loop + cancel lands `cancelled` (integration pins); CLI/upload byte-identical (unedited pins green); payload additive (`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`); suite green, coverage >90%, lint/types clean.
**Documentation** — 3 fixes: README sync "States" bullet had an **unclosed parenthesis** (pre-existing, 193/194) and the scan-label "total is 0" clause was ambiguous about a `=0` run (it still counts the walk once the import starts); **AGENTS.md rule 17 had a stray `)`** (pre-existing from phase 191). Also verified consistency both directions for all 87 settings ↔ README ↔ `.env.example` ↔ settings catalog, the pages table ↔ `app/main.py` routes, no stale `BOR_` names, and README/AGENTS claims through phase 194.
**Notes/deviations** — no live-infra changes (prod sync knob ships default-on, no deployment touched); no git index/history commands run (all changes left in the working tree for the harness); commit criterion left to the harness as instructed.
**Next pending phase:** none — `.agents/phases/todo/` holds only `194_sync_pending_count`, which is now ready to move to `complete/`.
Phase 192 — final verification pass complete.
- **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite.
- **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**.
- `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%)
- `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated)
- `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts)
- `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors**
- **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run).
- **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream).
- **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened.
- **Next pending phase:** `193_sync_remaining_count`.
All gates re-verified against a stable tree. Report:
**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
All gates re-verified green. Report:
- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
- strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
- PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
- live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
- byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
- suite green, coverage >90%, ruff+pyright clean — ✅
- no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
- atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
Convert the four open TODO items into executable phases (append mode,
starting at 190 after the completed 189):
190_app_name_settings_fix - the Settings-panel app name wins over the
Theme tab; the three brand strings stripped from the Theme tab
(colors-only); live brand re-apply on save, no reload
191_source_name_labels_tags - source_labels table + admin API + Sources
page editor; the separate bounded source_name_hit_bonus retrieval
boost; label surfacing in the <document> blocks and KB overview
192_verbatim_inline_midturn - an additive verbatim_block SSE frame at
the show_text success moment; in-place mid-turn fence hydration;
the done/restore per-locator dedupe; the done frame, record key,
restore and shared page stay byte-identical
193_sync_remaining_count - the running sync/upload label counts down:
"N remaining" (N = total - done), hidden at the final file; the
chunk/image/summaries/terminal parts untouched
TODO.md is cleared (its items now live in .agents/phases/todo/).
LLM-Generated: true
**Phase 189 final verification — all green**
- **Verified (all 4 tasks already implemented):** A1 caps in `app/schemas.py` — `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; `text`/`thinking` 600 000, `error`/`image` 500, `answer_text` 50, `messages` 500, 8 M budget all UNCHANGED; rationale docstrings rewritten to `merge_cited_docs`/`agent_max_rounds`. A2 `shareCurrentChat` linked branch upserts (PUT, 404→create+re-link) before `POST /{id}/share`, aborts on failure (banner, no token); unlinked branch byte-identical. A3 `savePending` + `finally` re-run. A4 `saveFailedSticky` + one 5 s `setTimeout` retry on network/5xx only, 4xx never retries.
- `uv run pytest --cov=app --cov-report=term-missing` → **4609 passed, 1 warning, EXIT=0**, `TOTAL 99%`, `app/schemas.py 100%` (>90%).
- `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` → **4 passed, EXIT=0** (isolation).
- Regression green & **unedited** (`git status` clean): `test_chat_save_payload_limits.py` 2, `test_chat_history.py` 5, `test_share_chat.py` 4. Extra blast-radius sweep (share/save/persist/segments surface, all EXIT=0): `test_save_share_ux`, `test_stale_saved_chats`, `test_history_pagination`, `test_history_copy`, `test_bottom_chat_actions`, `test_reasoning_bubble`, `test_tool_budget_telemetry`, `test_verbatim_show_text`, `test_smoke`. No leaked `saved_chats` rows (`save-long-%` = 0).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- **Completion criteria:** caps ✓ · incident payload 22/33/51 → 201/200 anonymously + 201 tools/451 segments/101 suggestions → 422 naming the offender, row unchanged ✓ · share-saves-first + abort + unlinked byte-identical ✓ · overlap re-run + sticky note + one-shot retry ✓ · phase E2E in isolation + 3 regression suites unedited ✓ · full suite / coverage / lint / types ✓ · `git diff --stat` = `app/schemas.py`, `frontend/assets/app.js`, 3 test files, `README.md` + new E2E & phase files (no `app/api/chats.py`, no migration, no new dependency) ✓ · commit + `todo/`→`complete/` move left to the harness (rules forbid git index writes).
- **Docs:** full README review (all 2 258 lines) + machine cross-check — all 85 settings documented in the Configuration reference, all 34 `.env.example` vars documented, documented defaults match `app/config.py`. Fixed 2 inconsistencies: (1) the 117-char prose line task 04 left when reflowing the Saved-chats pager paragraph; (2) the phase-189 sentence implying the 8 M total bounds the widened arrays — now states precisely that the total counts `text`/`thinking`/verbatim-block text only, and that the arrays are bounded by fixed item COUNT (matches `_require_within_total_budget`). AGENTS.md verified consistent — it enumerates no save contract or list cap (rule 5's phase-166 in-flight-turn clause untouched), so **no AGENTS.md change** per the phase mandate.
- **Notable/deviation:** the wider caps raise the *theoretical* anonymous-POST worst case (arrays are outside the 8 M char budget); left as designed (A1 fixes every other bound), documented honestly in README — realistic payloads stay ~10 MB, so the existing `client_max_body_size ≥ 16 MB` guidance still holds. No live-infra changes were made (data-repair of the two stale shared rows stays owner action).
- **Next pending phase:** none — `189_chat_save_long_turns` is the last entry in `.agents/phases/todo/`.
**Phase 188 — final verification pass: all criteria green.**
- **Verified (all 5 tasks already in `complete/`)**: migration `0029` (`CONCURRENTLY` in an autocommit block, INVALID pre-drop, post-validate, tested downgrade) — manual `alembic downgrade -1` → index absent, `upgrade head` → `indisvalid = t` (72 kB); `retrieval_ef_search` = **400** with `SET LOCAL` + clamp-and-WARN in `_vector_candidates`; CATALOG group *"Retrieval & honesty"*; D5 runbook in README.
- `uv run pytest --cov-reset` then `uv run pytest tests/unit tests/integration -q --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99 %** (6 946 stmts / 16 miss) — gate >90 % met. (A stale `.coverage` left by an earlier session produced a spurious 90.00 %/3-failure reading; `--cov-reset` cleared it.)
- `uv run pytest tests/e2e/test_vector_index_recall.py -v --no-cov` → **4 passed in isolation**; retrieval/chat/recall neighbours (`test_retrieval_quality`, `test_chat_rag`, `test_search_recall_small_sources`) → exit 0.
- `uv run ruff check .` → **All checks passed**; `uv run pyright` (whole repo) → **0 errors**.
- **Completion criteria**: 0029 DDL + tested downgrade ✅ · ef_search SET LOCAL/clamp/negative-refusal/CATALOG/.env.example/README ✅ (400 everywhere, no stale 100/150 pins) · plan pin + deterministic recall pin (≥0.99 @400, cosine ≤1e-6, monotonicity, vacuity guard) ✅ · live battery off/on recorded (`benchmarks/phase188_live_gate/task05_battery_index_{off,on}.txt` byte-identical, `deterministic_gate_recall.txt`, build 161 s / 1 625 MB / 81 MB RSS, phase report `.agents/reports/188_chunks_hnsw_index/`) ✅ · pytest/coverage/ruff/pyright ✅ · phase E2E ✅ · docs ✅ · commit left to the harness.
- **Documentation**: reviewed README + AGENTS end-to-end — all 85 `Settings` fields documented in README *and* `.env.example`, every path referenced by both docs exists (0 broken), `BOR_SETTINGS_DB` real, `BOR_THEME` correctly described as retired, runbook matches the measured `/dev/shm` = 63 MB finding, ef_search default consistent → **verified consistent, no changes needed**.
- **Notable**: built and ran a temporary offline synthetic recall gate (`--docs 1000`, 8 000 chunks) to re-check A7 without an endpoint; on near-orthogonal synthetic vectors exact parity only arrives at `ef_search ≈ 700` (400 → 0.91), which is a property of that corpus distribution, not of the index — exactly the counter-case already recorded in `tests/integration/test_chunks_hnsw_recall.py`. Because a synthetic corpus can't be calibrated to the real embedding cone without the endpoint, I **deleted the script** rather than ship a verdict that could be misread as production. `AIPI_KEY` is absent in this environment (and `100.117.2.24:1219` /embeddings times out), so the live battery could not be re-run — its recorded task-04/05 captures stand. Scratch DB `brain_of_reese_hnswgate` dropped; repo left clean.
- **Next pending phase**: `189_chat_save_long_turns`.
**Phase 187 — final verification pass: all criteria met, repo 100% green.**
Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.
Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)
Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.
Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).
Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.
Next pending phase: `188_chunks_hnsw_index`.
## Final report — phase 186 `git_log_streaming_dates`
**State found:** all three task files were already implemented and in `.agents/phases/complete/186_git_log_streaming_dates/`, so this run was the final verification + documentation pass.
### Verification (all green)
| Gate | Result |
|---|---|
| `pytest --cov=app --cov-report=term-missing` | **4531 passed, exit 0** · coverage `TOTAL 6872 stmts → 99%` (gate >90%) |
| `tests/e2e/test_git_log_stream_dates.py -v --no-cov` (isolation) | **5 passed** |
| `ruff check .` | All checks passed |
| `pyright` | 0 errors |
Implementation shape confirmed against the phase contracts: `_parse_commit_dates(str | Iterable[str])` (existing string pins untouched — only 3 non-test lines removed from `test_git_sync.py`), `stream_git_lines` (`Popen` + daemon stderr drain + per-line stdout + terminate-on-abandon, `run_git`-shaped `GitSyncError`), `file_commit_dates` on the stream with byte-identical dates and the fail-soft funnel unchanged, `async_sync` propagating the `to_thread` errors to the existing `except`. A11 holds: two `subprocess.` sites, both in `scripts/git_sync.py`, both off-loop.
### Documentation-consistency pass
Verified (clean): all ~100 `BOR_*` README defaults against a bare `Settings()` (the local `.env` produced false positives — none real), `.env.example` completeness, every module/route/page/test path referenced, `compose.yaml`/`Containerfile`/`entrypoint.sh` claims, E2E port conventions (8901 / 8123), palette count (17), chip cap (90), token format, payload limits, retrieval battery + audit commands.
Fixed (both files, prose only):
- **Page-naming drift** — README/AGENTS called the sync/catalog page "the Sources page" in 5 places, contradicting the README's own convention (and `frontend/index.html`): the **Sync sources** button and the document catalogue are the **RAG** page (`/sources.html`); "Sources" is `/git-sources.html`. Now `RAG page (/sources.html)` in README:186, 376, 418, 682, 713, 745, 1006, 1057 and AGENTS:19, 38, 183.
- **Phase-186 AST-pin wording** — "fails if a third spawn appears anywhere in `app/` or `scripts/`" overstated it (the pin *enumerates* the spawns, including the non-git Valkey probe in `app/core/caching.py`); rewritten to describe the enumeration.
- Moved `00_phase.md` into `complete/186_git_log_streaming_dates/` (AGENTS rule 2 moves the whole directory); `todo/` now starts at 187.
⚠️ **One mistake to disclose:** while tidying line wraps I ran `git checkout README.md`, which discarded task 03's uncommitted phase-186 README bullet. I restored it verbatim from the full `git diff` captured earlier in this session (plus the two corrections above) and re-verified the section renders as intended — no other README content was uncommitted, and AGENTS.md was untouched by the checkout. A blanket line-length rewrap also reflowed 4 unrelated lines; all four were restored to their exact original text (`git diff` now shows only intended changes).
Nothing was committed or staged — the working tree is left for the harness: modified `scripts/git_sync.py`, `app/api/sync.py`, `app/rag/git_sources.py`, `tests/unit/test_git_sync.py`, `tests/integration/test_git_file_dates.py`, `README.md`, `AGENTS.md`; new `tests/e2e/test_git_log_stream_dates.py`, `.agents/phases/complete/186_git_log_streaming_dates/`, `.agents/reports/186_git_log_streaming_dates/`.
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`.
All gates green; no code changes were needed — the prior failure was a missing final report, not a test failure.
**Verified / implemented (phase 184 final pass)**
- All 4 tasks present in `.agents/phases/complete/184_sync_bounded_session/`; confirmed their output in the worktree: `sync_session_reset_files=200` / `sync_prune_batch=500` on `Settings` (+ non-negative validators), both `SettingMeta` rows under `"Import & sources"`, `.env.example` + `README.md` entries.
- `import_sources` expunges at **exactly two** sites (per-file loop top L972, source boundary L1012) — grep proves no third `expunge`/`expire_all` anywhere in `app/`; `app/db.py:30` untouched.
- `_prune` uses `load_only(id, source, path, is_image, image_path, page_count).order_by(Document.id)` + per-batch commits, with the phase-122/126 guards and `changed_paths` intact.
**Tests / lint / coverage (all 100% green)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4506 passed**, `app/` TOTAL **99%** (>90%).
- `uv run pytest tests/integration/test_import_session_retention.py -v --no-cov` → **12 passed**.
- `uv run pytest tests/e2e/test_sync_session_reset.py -v --no-cov` → **4 passed in isolation**.
- Regression sweep of neighbouring sync suites (each isolated): `test_sync_changed_only` 3, `test_sync_summary_visibility` 3, `test_prune_cascade_cleanup` 3, `test_sync_git_freeze` 2 — all passed.
- `uv run ruff check .` → All checks passed. `uv run pyright` → 0 errors, 0 warnings.
**Completion criteria**
1. Settings + CATALOG + `.env.example` + README; `test_settings_catalog.py` → ✅ green
2. Retention test green with the post-fix bound and both measured numbers in the docstring (pre-fix 12-row monotone staircase / ≈265 MB extrapolated; post-fix ends at 0, add-path peak 14 vs 21→49) → ✅
3. Expunge only at the two documented-safe points; light-row batched prune with guards intact → ✅
4. Every existing importer/sync/removal test green with unchanged expectations → ✅
5. Full suite green, coverage >90%, ruff + pyright clean → ✅
6. Phase E2E green in isolation → ✅
7. README + AGENTS accurate/consistent → ✅
8. Atomic commit — left to the harness (I ran no git write commands).
**Documentation** — Full README (1886 lines) + AGENTS.md (189 lines) review: **verified consistent — no changes needed.** Programmatic cross-checks: every `Settings` field appears in `.env.example` and in the README config reference; every `.env.example` var is documented in that reference; every `tests/…` and `app/…` path cited in README/AGENTS exists; all 10 README page URLs are registered in `app/main.py`; documented E2E port vars (`E2E_APP_PORT_SYNCRESET[_ZERO]`) exist in the test. The only reference-var without a `Settings` field is `BOR_THEME`, which the README explicitly documents as retired (correct as written).
**Notable decisions/deviations** — None. No code, test, or doc edits were made this pass; changes remain entirely in the working tree for the harness to stage. `00_phase.md` still sits in `todo/` while tasks 01–04 are in `complete/` — harness-owned, so I left it untouched.
**Next pending phase:** `185_sync_defer_content_fast_path`.
All gates green. Final state verified.
**Implemented / verified**
- Task 03's work was **never actually done** (its report was a stub, `tests/e2e/test_prune_cascade_cleanup.py` did not exist) → wrote it: 3 tests — 9-file local source synced from the Sources page (`9 added`), 6 files deleted upstream, re-sync (`0 added · 3 unchanged · 6 pruned`), documents vanished from the tree + `GET /api/documents/content` 404 + total 3, **0 orphan chunk rows** via the test's own SQL, survivor chunk total unchanged; partial-prune chat turn (grounded, non-deflected, `query_log` names the survivor and no pruned doc); total source removal (`DELETE /api/git-sources/{id}`) wipes every doc+chunk of the source with 0 orphans, sibling source untouched. Mock-only (`E2E_REAL_LLM=1` skipped), own port 8199, truncate before/after.
- Tasks 01/02 re-verified: `passive_deletes=True` + mapper pin, probe flipped to `EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC = 0`, `_prune`/`delete_git_source` docstrings.
- **Drift guard proven non-tautological by my own one-off run**: scratch DB (`alembic upgrade head`) → FK `confdeltype='c'`, guard PASSES, CASCADE delete leaves 0 chunks; FK recreated `ON DELETE NO ACTION` → guard FAILS verbatim and a `passive_deletes` delete raises `ForeignKeyViolation` (never silent orphans). Scratch DB dropped; shared test DB untouched (`'c'`).
**Gates**
- `uv run pytest --cov=app --cov-report=term-missing` → **4488 passed**, `app/` TOTAL **99%** (>90%)
- `uv run pytest tests/integration/test_prune_cascade_statements.py -v` → 4 passed
- `uv run pytest tests/e2e/test_prune_cascade_cleanup.py -v --no-cov` → **3 passed in isolation**; `test_source_removal_cleanup.py` 6 passed, `test_force_push_resync.py` 2 passed (both isolated)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Completion criteria**: passive_deletes+pin ✅ · probe post-fix ✅ · drift guard green + proven-failing variant ✅ · full suite/coverage/lint ✅ · E2E in isolation ✅ · docs consistent ✅ · commit = harness-only (per pipeline rule I ran no git write commands).
**Docs**: fixed 4 pre-existing README inconsistencies — per-module E2E ports (claim of "one pair of fixed ports" predates phase 79), debugpy `launch.json` snippet vs the shipped file (`name`/`remoteRoot`), and the page-naming wobble (navbar labels `/sources.html` **RAG**, `/git-sources.html` **Sources** — the Sync button's home is now named correctly in 4 places); added the phase-183 E2E pointer. AGENTS.md verified accurate (rule 12 matches the delete-site audit).
**Deviation**: none from the locked decisions; the E2E is a behaviour pin (byte-identical owner-visible behaviour), the statement-count probe remains the memory teeth, as phase 183 D1/A4 specify.
**Next pending phase**: `184_sync_bounded_session`.
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
All verification complete. Final state summary:
**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
- `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
- ``→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
- `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
- Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
- Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
- `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
All gates green; working tree holds exactly the phase's changes for the harness to commit.
## Phase 179 — Final verification report
- **Verified (all 3 tasks already complete in `complete/`)**: repro + CONFIRMED verdict, `asyncio.to_thread` fix, dedicated E2E suite.
- `app/api/sync.py::_run_sync`: both git ops (`clone_or_pull`, `file_commit_dates`) now `await asyncio.to_thread(...)`; phase-134 boundary note + module docstring document worker-thread-runs-to-completion and the A3 DB-calls-stay-on-loop note.
- **Tests / lint / coverage** (full suite 100% green):
- `uv run pytest tests/integration/test_sync_git_freeze.py -v` → **4 passed** (post-fix probe <1.5 s during 3 s stub)
- `uv run pytest tests/e2e/test_sync_git_freeze.py -v --no-cov` → **2 passed in 25.65 s in isolation** (wall guard 120 s holds)
- `uv run pytest --cov=app --cov-report=term-missing` → **4173 passed**, `app/` coverage **99%** (`app/api/sync.py` 100%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
- **Completion criteria**: ① integration probe green + verdict (CONFIRMED, mechanism, measured 3.04 s→0.003 s) in docstring — **met**; ② off-loop git ops + boundary/A3 notes — **met**; ③ full suite green, coverage 99% >90%, ruff+pyright clean — **met**; ④ E2E green in isolation (responsive-poll + cancel-during-clone contracts) — **met**; ⑤ atomic commit + move to `complete/` — **harness-owned** (pipeline rule overrides per-phase self-commit; all changes staged-ready in the working tree).
- **Documentation**: full README (1526 l) + AGENTS.md review — phase-179 bullets present and accurate (verified against fixture constants, `.env.example` fully covered by the config reference, pages table matches `app/main.py` shell routes, quick-reference commands/artifacts exist); **consistent — no further changes needed**.
- **Notable**: task-03 pipeline sessions died on context overflow but their harness validation passed; this pass independently re-ran every gate to confirm. No live infra touched; no git index commands run.
- **Next pending phase**: `180_markdown_inline_links_images`.
The root-level image.png was committed accidentally during phase
160 (tool budget telemetry) as a bug-report screenshot. It is not
served or referenced as an asset by the app; the remaining
"image.png" mentions in tests and comments only describe the
owner's screenshot in prose, so removing the file is safe.
LLM-Generated: true
Answers to "show me my pi.dev config" re-type file contents from context,
so the code block in the answer can differ from the indexed file by a
character and cannot be trusted for copy-paste — the owner opens the
source bubble and ignores the answer.
Phase 182 makes the server the only party that supplies bytes:
- show_text(path, offset?, limit?), the display sibling of show_image,
slices documents.content and hands the same bytes back to the model
with a "do not retype" instruction
- inline ```bor source/path 12-40 locator fences place a resolved block
anywhere in the prose; the model's fence body is never painted, and an
unresolvable locator says so instead of showing guessed text
- the bytes ride inline in the done frame's additive answer_text array
and persist in the saved chat, so restore and the shared page match
- line numbers are always the server's resolved extent (overshoot
clamps, past-the-end refuses to the model), which removes the
"did the model invent those numbers?" problem entirely
- a Copy button on every answer code block, a VERBATIM badge only on
server-resolved ones
- gated by BOR_ANSWER_TEXT with per-block/per-answer budgets; with the
flag off every frame, record and byte budget is exactly pre-phase
Overview plus eight tasks, each with its own test gate; the phase E2E
runs in isolation and asserts the clipboard content equals the stored
bytes. Run 180 first — both touch markdown.js.
LLM-Generated: true
Convert the 3 open TODO.md items into sequential phases appended to the
existing pipeline (Protocol B, numbered after 179):
- 180_markdown_inline_links_images: the shared escape-first renderer
gains the [text](url) link transform, the  image
transform, and the ####-###### heading levels, with CSS and the
dedicated Playwright suite.
- 181_svg_document_preview: SVGs index as vector image documents
(text extracted from title/desc/text, no vision call), are served as
sandboxed image/svg+xml, and preview as graphics instead of raw XML.
TODO.md is cleared; its items now live in .agents/phases/.
LLM-Generated: true
TODO L3 (a long git clone seems to freeze the server) converted to phase 179:
deterministic in-process repro + verdict, the asyncio.to_thread fix for the
sync's git ops, and the dedicated Playwright E2E suite. TODO.md cleared
(items now live in .agents/phases/).
LLM-Generated: true
## Phase 178 — final verification pass: 100% green
**Verified (all 5 tasks complete in `complete/178_split_thinking_levels/`):**
- `config.py` rename (`llm_chat_thinking_level` default `low`) + new `llm_summary_thinking_level` (default `""`); two `CATALOG` rows; `.env.example` replaced; no stale old-key references (only intentional negative pins in tests)
- Wire split: `chat_stream()` → chat level, one-shot `chat()` → summary level (absent `extra_body` when empty); per-turn line reads chat level only
**Gates:**
- `uv run pytest -k "config and thinking" -v` → 7 passed (defaults + env overrides both knobs)
- `uv run pytest` (unit + integration) → 4170 tests, exit 0; `--cov=app` → **99%** (>90% ✓)
- `uv run ruff check .` clean; `uv run pyright` clean
- `uv run pytest tests/e2e/test_split_thinking_levels.py -v --no-cov` → 3 passed in isolation
- `test_admin_settings_panel.py` + `test_admin_settings_layout.py` → 17 passed in isolation
**Completion criteria:** all 6 test/doc criteria ✅ (admin GET lists both keys, old key gone — integration + E2E pins); commit + phase-dir move = harness's job.
**Documentation:** README.md — both new env vars correctly documented in the LLM (aipi) table with defaults/wire-mapping/off-rule, defaults match `config.py`, all `.env.example` vars covered: verified consistent, no changes needed. AGENTS.md — all referenced files/commands verified to exist, no phase-178 impact (no UI code change, A4): verified consistent, no changes needed.
**Decisions:** none required — no live changes made (E2E booted its own app subprocess against the existing DB).
**Next pending phase:** none — 178 is the last phase in `todo/`.
All documentation claims verify against the code. Everything is green — final verification complete.
## Phase 176 — Final Verification Report
- **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only).
- **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match.
- **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`.
- **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed.
- **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope.
- **Next pending phase:** `177_themed_favicon`.
The single TODO item (mobile settings Value inputs impossibly small) becomes phase 176_mobile_settings_value_width: 3 tasks (CSS fix in the <=640px block, unit pins, dedicated E2E suite) with owner-locked A1-A3. Protocol B append - PLAN.md and AGENTS.md untouched.
LLM-Generated: true
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.
**Phase 175 final verification pass — report**
- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
All checks pass. Both docs verified consistent. Final state — no live-infrastructure changes were needed (pure CSS fix; verification ran against the already-running local dev DB).
**Phase 174 final verification pass — report**
- **Verified (no new work needed; both tasks already complete in `complete/`):** the single `#git-sources-content { display:flex; flex-direction:column; gap:1.25rem }` rule in `frontend/assets/styles.css` (with the phase-174 root-cause comment), `.git-sources-shell` untouched, the 4-test unit pin module, and the 4-test Playwright suite.
- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → **4116 passed, 99% `app/` coverage** (gate >90% ✓).
- **Lint/types:** `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Dedicated E2E in isolation:** `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` → **4 passed** (1440px + 375px bounding-box rhythm pins 20±2px, hidden-children + revealed-joins-rhythm, anonymous gate, a11y/no-CDN).
- **Regression set, each in isolation:** `test_git_sources_admin.py` **6 passed**, `test_archive_upload_sources.py` **5 passed**, `test_git_source_dates.py` **4 passed**.
- **Completion criteria:** cards stack with uniform ≈20px gap at both viewports ✓; hidden children stay hidden / gate+content flip intact ✓; ≤640px forms column-stack + table scrolls inside its card (no page-level h-scroll) ✓; full suite + coverage + ruff + pyright ✓; dedicated + regression E2E suites ✓.
- **Documentation:** README.md reviewed in full (every `.env.example` var documented; all documented routes/pages/endpoints exist; every config default cross-checked against `app/config.py`; payload-limit table, ports 8123/8901, Containerfile/compose/entrypoint claims all match) — **verified consistent, no changes needed**. AGENTS.md verified (all referenced files/sections exist: PLAN §2/§7.1/§7.4/§9, `response_budget.py`, `debugging.py` conditional import, `.agents/` tracked + gitignore rules, `commit.gpgsign=false`, phase-pinned CSS classes) — **verified consistent, no changes needed**.
- **Notable:** commit + phase-dir move are handled by the harness per pipeline rules (no git history/index touched by this pass); no live-infrastructure changes were required.
- **Next pending phase:** `175_history_pagination` (in `todo/`).
All gates green. Final report:
**Phase 173 — final verification pass (all tasks already complete in `complete/`)**
- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
5. Full suite green, coverage >90%, lint clean — ✅
6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
7. Commit + phase move — harness's job (per instructions, not mine).
8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
Expected state: only `00_phase.md` remains in `todo/` (the harness moves it on success; I must not touch it). All verification is complete.
---
**Phase 171 final verification pass — report**
- **Verified (all 4 tasks complete, CSS-only diff confirmed byte-exact to contract):** `#settings-content` flex column + `gap: 1.25rem` (tasks 01); `.settings-group-row th` band `--brand-soft` + 700 + `--line` top hairline (02); `#settings-save:disabled` `cursor: default` (03); new phase-dedicated Playwright suite (04). Mobile media queries and `settings.js`/`app/` byte-untouched, as pinned.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4069 passed, app/ 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**.
- **E2E (in isolation):** `test_admin_settings_layout.py` → **5 passed**; `test_admin_settings_panel.py` (phase 168) → **12 passed**; `test_wide_desktop_column.py` → **3 passed**.
- **Completion criteria:** search/table no-intersection + gapped action row at 1440/375 px ✅ · distinct group bands (bg ≠ card, weight 700, ≥1px top hairline) ✅ · disabled Save `cursor: default` with §7.4 dim + aria-busy lifecycle intact ✅ · full suite + coverage + lint ✅ · all three E2E suites isolated ✅ · atomic commit + dir move = **harness's job** (task files already in `complete/`; per protocol I left every change uncommitted in the working tree).
- **Documentation:** README.md (all 1487 lines) and AGENTS.md (all 80 lines) fully reviewed — every config default cross-checked vs `app/config.py`, all quick-reference commands verified against existing files, AGENTS.md rules/contracts verified vs code — **verified consistent, no changes needed** (phase 171 is CSS-only: no setup/command/env/API impact).
- **Notable:** one phase-168 test failed on the first isolated run solely because an unrelated `undercooked` dev server held port 8124 (the suite's `DISABLED_PORT`); re-ran with the suite's built-in `E2E_APP_PORT=8300` override — no code change, foreign process untouched. Post-run DB state clean (`app_settings` 0, `ui_settings` 0, e2e tokens 0).
- **Next pending phase:** none — phase 171 was the last `todo/` phase.
**Phase 168 — Admin Settings panel — final verification: PASS**
1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
170_edit_bubble_sizing — the edit form renders inside the user bubble
(same box, no shrink) and its submit label reads "Send", not "Save"
(TODO.md L3).
171_admin_settings_layout — settings page CSS fixes: #settings-content
vertical rhythm (search/table overlap + smushed action row), distinct
group bands, a properly disabled Save cursor (TODO.md L4).
Numbering note: 169 (embedding generation migration) was created,
executed and abandoned before this conversion; its number is not
reused. TODO.md is cleared — the items now live in .agents/phases/.
LLM-Generated: true
The embed alias was reverted to the previous backing model and re-benchmarked
over 3 runs. All gates pass and the numbers reproduce the original baseline
exactly: 768 dims, cosine margin 0.283 (quality 56) versus the 0.217 measured
on the swapped-in model, with throughput back to 109.7-123.3 vec/s.
The margin returning to the identical 0.283 confirms the locked semantic-pair
corpus is a reliable fingerprint for this endpoint, and a raw-vector probe
shows the two models are genuinely different spaces despite both being
unit-normalized 768-dim (max component diff 0.035 on the same input). Any
content embedded while the swapped-in model was live is therefore in an
incompatible space and needs re-embedding.
LLM-Generated: true
Second 3-run pass against the re-pointed embed endpoint the same day.
Dimension stays 768/768 and the cosine margin reproduces exactly at 0.217
across all passes, confirming both the model's determinism and that the
margin drop from the 0.283 baseline is a real effect of the model swap.
Throughput measured 59.1/61.4/57.4 vec/s against 35.5/35.6 earlier, a 1.7x
swing on an unchanged model, which shows the speed check measures one batched
request's end-to-end wall time (TLS setup, network, server queueing) rather
than stable throughput - note it gates on NaN/Inf only, never on rate.
LLM-Generated: true
An embedding-model swap that keeps the dimension is invisible on this
app: nothing records which model made a vector, EmbeddingDimensionError
cannot fire at 768 -> 768, and a re-sync re-embeds nothing because the
documents.file_hash fast path returns with zero embeds - so questions
embed in one geometry and chunks score in another with no error anywhere.
The owner's upgrade (embedding gemma -> embedding gemma 2) is exactly
that case.
Phase 169, seven tasks: chunks.embedding_model (reversible migration
0029, nullable, never backfilled - NULL means unknown by design), one
write helper so every vector write stamps its generation, a
generation_report() that turns the column into counts plus a stale
verdict logged after every sync, scripts/reembed_embeddings.py with
--compare (cosine agreement against the stored vectors, so 'are they
compatible?' is measured instead of guessed) / --stamp-only / --apply
(per-batch commits, resumable, never a NULL embedding, never a
sources_meta bump), an admin-only GET /api/admin/embeddings, a read-only
card in the phase-168 Settings view, and the dedicated Playwright file
plus the README runbook.
Owner-locked during authoring: detection only - retrieval never filters
by generation (a WHERE embedding_model predicate would silently deflect
every turn mid-backfill), comparability bands COMPATIBLE >= 0.995 for
the float4 round trip, a re-embed is not a KB change so saved chats stay
fresh, and no new Settings field so the phase-168 catalog pin is
untouched.
.agents/skills/migrate-embed-model is the operator twin: probe and
benchmark the new model, measure comparability as its own process
without disturbing the running app, back up and migrate production
(idle check, pg_dump off-box, alembic, flip then re-embed with the same
env), then re-tune relevance_threshold and the two floors against the
retrieval battery - after the corpus is on one generation, and with the
phase-168 warning that an app_settings row beats .env - and smoke-test
the result.
LLM-Generated: true
The aipi `embed` endpoint was re-pointed to a new backing model and was
re-benchmarked over 3 runs. All three gates pass: output stays 768-dim
(matching BOR_EMBEDDING_DIM, so no schema/vector change is needed), the
similar-vs-dissimilar cosine margin is 0.217 (gate >= 0.05) with no NaN/Inf
vectors, and throughput is 35.6 vec/s on the 50-vector probe.
Versus the previous embed baseline (margin 0.283, 93.8 vec/s) the margin
tightens and throughput drops ~2.6x, both consistent with a model swap
behind the same alias. Vectors are unit-normalized and deterministic.
Note: same dimension on a different model means any KB indexed under the
old backing model holds stale vectors and needs a re-embed; the local dev
DB is empty, so the phase 119/162 retrieval battery was not runnable here.
LLM-Generated: true
One owner item, one phase: an admin-only Settings view that writes any
BOR_* setting to the database, resolves DB-over-env inside
get_settings(), and applies on Save without a restart. Six tasks: the
app_settings table + reversible migration 0028, the overlay resolver and
field catalog, the live-apply reset of the two in-process singletons
(the shared LLMClient and the chat semaphore), the admin-only
/api/admin/settings surface, the shell's eighth view, and the dedicated
Playwright suite.
Owner-locked during authoring: DB-only persistence (.env is never
written), the overlay lives inside get_settings() behind the
BOR_SETTINGS_DB kill switch, database_url and embedding_dim are not
DB-overridable, boot-wired settings stay editable with a "restart
required" badge, secrets are masked and write-only, and every write is
validated by the real Settings validators while an unusable stored row
is skipped at read so boot can never brick.
Protocol B append: PLAN.md and AGENTS.md are untouched, so PLAN's
roadmap table does not list phase 168 and its "get_settings() is
lru_cache'd" note in 13 is extended (cached env base + DB overlay +
invalidation). TODO.md's L3 item is now cleared - it lives in this
phase directory.
LLM-Generated: true
All gates green. Final report:
**Verified (phase 167 final pass — all 6 tasks already in `complete/`)**
- Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte.
- Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`.
**Gates (all 100% green)**
- `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line).
**Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines):
- Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed.
- AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree).
- Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere.
**Notable decisions / deviations**
- `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's.
- A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened.
- Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run.
**Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.
- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
- `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
- `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
- `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
Audit of the written phase found the depth=1 default was specified without
naming two of its nine consumers, both of which walk the tree without a
depth parameter and assert phase-106 D9 dates:
- tests/integration/test_docs_api_dates.py (reaches a -> a/b -> deeper)
- tests/e2e/test_git_source_dates.py (_find_file recurses by design)
Task 02 gains item 9 (both migrate by requesting ?depth=8 explicitly with
every assertion intact, plus the truncation-proof pin that a truncated
folder's updated_at still equals the deep max), task 06's isolation list
names the E2E suite, and the overview's D2 now carries the full nine-file
consumer list. Also corrected the Context anchors: build_kb_tree is L780
(not 781) and _subtree_max is L760 (not 746).
LLM-Generated: true
Three owner-reported defects become executable phases; TODO.md is cleared
once the roadmap is written (Protocol B append, PLAN.md/AGENTS.md untouched).
- 165_prose_word_wrap (TODO L3): image descriptions and .txt bodies render as
a wrapping .doc-prose block instead of <pre class="doc-raw"> (page + modal via
the one renderDocument core). Code and code-ish formats keep horizontal scroll
- phase 116 locked A1 survives; extracted PDF text stays raw and the deferral
is recorded as an owner call, not an oversight.
- 166_hide_empty_answer_bubble (TODO L4): .msg .bubble:empty { display: none }.
CSS-only, so the phase-158/161 "reasoning container inserts before the bubble"
anchor and its unit pins stay untouched; the box returns at the first delta,
and the shared/restore empty-answer corners take the same path.
- 167_api_payload_diet (TODO L5): selective pure-ASGI gzip (SSE /api/chat skipped
by exact path), a depth-limited KB tree with server-side totals + truncated
nodes whose recursive updated_at comes from a new aggregate map, a
GET /api/docs/tree/node drill route, content-hash weak ETag/304 on the catalogue
reads (NOT sources_meta - bumping it would mark saved chats stale), the lazy
client drill, and a /api/* byte-budget log line plus scripts/api_payload_audit.py
whose measured numbers land in AUDIT.md.
Phase 97's "one fetch, drill client-side" contract is deliberately superseded
(locked A3); phase 116 A1, phase 158/161 anchors and A19/A20 middleware order are
explicitly preserved.
LLM-Generated: true
Captures the 2026-10-07 live '[object Object]' finding: a >32k-char
answer rides back as history, the 422's list-of-objects detail hits the
unguarded body.detail assignment, and the zero-frame settle lies about
a dropped connection. Caps raise as a chain (HistoryTurn + ChatMessage
mirror per phase-83 SEC-05) with a bounded anonymous total; the budget
trim stays locked and untouched.
LLM-Generated: true
Captures the 2026-10-07 live findings on Letumdei_ Heirs to the Imperium:
a single sparse map page flipped a text PDF into the strip-only viewer,
and the strip's page copies were lost from disk while page_count stayed
stamped (no self-heal path). Owner decisions recorded: additive
text+Pages toggle for the pictorial class, write-before-delete atomic
persist, deterministic zero-LLM fast-path re-gate.
LLM-Generated: true
The owner refinement (2026-10-06): the per-run thinking and tool-call
blocks inside the Reasoning bubble were individually collapsible
details elements — a fold inside the fold. A tool frame closed the
previous thinking block, so the visible thinking vanished mid-turn;
and the pinned-tail follow died on the append of every run after the
first (the pin was captured AFTER the new block's height growth
contaminated the 32px reading).
- app.js: the in-container runs are now plain divs —
createThinkingRunEl (div.thinking + static .thinking-summary label +
.thinking-text) and createToolRunEl (div.tool-calls-disclosure +
static .tool-calls-summary label + the .tool-calls list); they never
open or fold (the container is the ONE open/fold control, open
through the turn, folded at settle — unchanged); the pin capture
moves BEFORE ensureThinkingRun/ensureToolRun append (plus the
first-frame edge: no container yet -> always pinned);
closeCurrentRun just ends the run; closeAllRuns stays the defensive
legacy guard; restoreTimeline/addSegments (shared.js) use the div
builders; the legacy no-segments restore path keeps the old
details-based blocks byte-identical
- styles.css: the in-container runs are distinct by label + rail
(brand-ink Thinking label on a brand-soft rail; accent-ink
Tool call(s) (N) label on a neutral rail); the interactive
treatment (cursor + chevron) is scoped to the legacy <summary>; the
inner windows stay disabled (the container's .reasoning-body is the
ONE live scroll surface)
- unit + E2E: the D5-refinement contract is pinned — one
details.reasoning disclosure, per-run blocks as plain divs, zero
nested <details> inside the container, the container as the only
open/fold control, the .reasoning-body window auto-follows the
tail while streaming
unit+integration 3738 passed (coverage 99%), ruff + pyright clean, all
five affected E2E suites pass in isolation (reasoning_bubble 6,
thinking_timeline 6, thinking_display 5, thinking_scroll 8,
tool_call_compact 4), verified live: the follow holds through a long
thinking stream, the tool-frame transition, and the second thinking
run; restore renders the div structure without client-side errors
LLM-Generated: true
All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.
**Verification complete — full results:**
- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
All green. Final report:
**Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)**
- **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers.
- **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings.
- **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes).
- **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched.
- **Next pending phase:** `161_reasoning_bubble`.
TODO.md's two open items become executable phase directories under .agents/phases/todo/:
- 159_edit_and_resend_message (5 tasks): inline edit affordance on the last
user bubble, in-place resend that truncates the replaced turn, an editable
error turn after an oversize question, and the Playwright suite
tests/e2e/test_edit_and_resend_message.py.
- 160_tool_budget_telemetry (5 tasks): remaining tool-call count in every
tool result, tool calls rejected (never shown) once the budget is spent,
the additive done.budget_exhausted flag + rounds=/cap_hit= log fields,
the Continue researching affordance, and
tests/e2e/test_tool_budget_telemetry.py.
No application code changed; the TODO lines are recoverable from git history.
LLM-Generated: true
All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
All verification is green. Final report:
**Phase 157 — final verification pass (all 3 tasks already complete in `complete/`)**
- Verified the implemented work matches the design: `CHIP_CAP = 90` constant + display-only cap in `renderChips` (`slice(0,90).trimEnd() + "…"`), `title`/`aria-label`/click payload byte-unchanged; unit pins (89/90/91 letter & space, full-text invariant, short-string guard, Maybe-try single-path guard); phase-104 E2E updated to the cap contract
- Full suite `uv run pytest --cov=app --cov-report=term-missing`: **3578 passed**, app/ coverage **99%** (>90% ✓)
- `uv run ruff check .` clean; `uv run pyright`: 0 errors
- Dedicated E2E `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov`: **4/4 passed** in isolation (DB up)
- Cross-phase regression: phase-104 E2E 6/6, phase 05/80/103 chip suite 8/8 (seed chips <90 chars — cap is a no-op, assertions hold)
- Criteria: ≤91-char capped display ✓ (byte-for-byte pin) · click submits FULL 120-char question (user bubble) ✓ · `title` + clipped-case `aria-label` carry full text ✓ (375px viewport) · <90-char byte-identical ✓ (unit + live DOM) · commit: deferred to the harness (per harness protocol, overrides AGENTS.md rule 8 for this pass)
- Documentation: README.md reviewed in full — every `.env.example` var documented, all referenced scripts/pages/commands verified against the tree, the phase's chip-cap note is in the Chat section; AGENTS.md verified (commands, test files, layout-rule citations — phase 157 adds no new layout principle) → **verified consistent, no changes needed**
- No live-infrastructure changes were required or made
- Next pending phase: **158_thinking_timeline**
Everything is verified. All gates green on the current working tree. Final report:
**Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)**
- **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin)
- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors)
- **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me)
- **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required)
- **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs
- **Next pending phase:** `157_chat_chip_cap`
All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.
## Phase 153 — final verification pass report
**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched
**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)
**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.
**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.
**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.
**Next pending phase:** `154_sync_status_freshness`.
All criteria verified. The CLI path is hook-less (only `app/api/sync.py` passes the hook), and the byte-identity is pinned by `test_on_image_progress_none_run_is_byte_identical`.
## Final Report — Phase 152 final verification pass
- **Verified (no new code needed):** `SyncStatus.images_done/images_total` + reset/clear rules in `app/api/sync.py`; `on_image_progress` threaded through the importer's three PDF vision spans with the continuous per-file counter; the `_image_hook` closure; `sources.js` `· image n/m` label part (box + title + aria).
- **C1** status climbs 1→N + UI shows `· image n/m`: ✅ unit 3/15→15/15, integration 1/15→7/15, E2E 1/4→4/4 (title/aria ride the full string)
- **C2** never frozen: ✅ E2E asserts ≥3 distinct positions, starting at 1, non-decreasing, ≤ total, reaching it
- **C3** cleared at overview/summaries + all terminals; per-file reset via `_hook`; no stale position for image-less files: ✅ unit pins + E2E settle (image part gone in box/title/aria, endpoint 0/0)
- **C4** `/api/sync/status` additive-only: ✅ full-dict pin passes (all pre-existing keys unchanged + the two new 0/0 keys)
- **C5** hook-less caller byte-identical: ✅ CLI passes no hook; `test_on_image_progress_none_run_is_byte_identical` green
- **C6** `uv run pytest`: **3526 passed, exit 0** (95s); dedicated E2E in isolation: **2 passed** (95s); `uv run ruff check . && uv run pyright`: **clean**; coverage: **99% on `app/` (>90% gate)**
- Regression: all five neighbouring E2E suites green in isolation (progress_box 8, chunk_progress 2, label_mobile 4, pdf_support 6, image_documents 4)
- **C7** atomic commit: left to the harness (no git index/history touched per rules)
- **Docs:** README.md — sync-label paragraph already updated this phase, accurately; all `.env.example` vars documented; routes/commands verified — **no changes needed**. AGENTS.md — all references (PLAN, user_stories, test_smoke.py, .env.example, gpg config, .gitignore) verified — **no changes needed**
- **Defects found in prior work:** none (one prior env-leak defect was already fixed inside this phase's task 04, conftest)
- No live-infrastructure touched; no ntfy used. **Next pending phase:** none — `todo/` held only this phase
The 149/150 CSS changes touch the sources-page sync UI visible behind the modal, so the regenerated screenshot is the accurate record of the current UI (house convention — the suite rewrites it each run; it was re-committed in phases 144/145/148 the same way). I'll keep it in the working tree.
All verification is complete. Final state:
**Phase 151 — final verification pass: complete, project 100% green.**
- **Verified (all 6 task files in `complete/`):** dedupe (xref + digest), SMask→alpha-PNG compositing (raw-format gate first, fail-soft), pure `is_blank_image` gate + log-only counters, `BOR_PDF_IMAGE_SKIP_BLANK` knob (default on), unit + real-file pins, dedicated Playwright suite
- **Real-file pins pass:** `podman-systemd.pdf` → 48 placements, 39 deduped, 9 blank-skipped, **0 image docs out**; `pdf_horse_test.pdf` → masked JPEG composited to alpha PNG, kept; `players_handbook.pdf` → 586 `jpx` images untouched (raw mime, ext-gate skipped)
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, TOTAL 99%** (pdf.py 100%, importer.py 99%, config.py 100%); dedicated E2E `tests/e2e/test_pdf_image_extraction.py` in isolation → **5 passed**; **full E2E sweep: all 135 suites run in isolation, 684 tests, 0 failures**; `uv run ruff check .` clean; `uv run pyright` → 0 errors
- **Completion criteria:** all 7 met (sync `detail` schema untouched — `app/api/sync.py`/`app/schemas.py` unmodified; counters in the shared `import_sources` → `summary.log()` path; commit left to the harness per instructions)
- **Docs:** README.md and AGENTS.md reviewed in full — verified consistent, **no changes needed** (env-var coverage claim, page/route table, production-deploy claims all check out; the phase's README/`.env.example` additions match the code). Note: PLAN.md §7.1's 46rem chat column is superseded by phase 100 (AGENTS.md rule 5 states this correctly; PLAN.md is out of my edit scope)
- **Flagged deviations (documented in-code per rule 3, not silent):** tolerance ±17 vs pinned ±12 — empirically verified ±12 lets the podman page-47 underlay (uniform 0.954) survive, violating criterion #1; ±17 is the smallest band keeping all real-file pins (mean 235 / fraction 0.98 unchanged); gate measures the base stream, not the premultiplied composite (which darkens transparent regions and would miss the underlays)
- **Next pending phase:** `152_sync_progress_image_pass`
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass.
- Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP.
- Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests).
- Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`).
- Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed.
- Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched.
- Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
Converts the three open TODO items into executable phases (Protocol B
append, numbered after the existing 149):
- 150_drop_sync_result — retire span.sync-result; #sync-progress becomes
the single sync surface and the sole aria-live region (TODO L5)
- 151_pdf_image_extraction — dedupe PDF image occurrences (46 placements
-> 15 unique in podman-systemd.pdf), composite /SMask so masked images
are not blank, and skip near-white decorative underlays (TODO L4)
- 152_sync_progress_image_pass — report the PDF vision-image position so
the sync label stops freezing at the last chunk (TODO L3)
Both reported bugs were reproduced against test_sources/podman-systemd.pdf
before authoring; root causes are recorded in the phase overviews. The
TODO file is cleared now that its items live in .agents/phases/.
LLM-Generated: true
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.
**Phase 149 final verification report**
- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
All defaults match. Verification complete — everything is green with no defects found.
**Phase 147 final verification pass — report**
- All 3 task files found in `complete/147_sync_changed_only/` → verification pass only; no code changes needed.
- Verified: migration 0025 (`documents.file_hash` nullable String(64), no index) + model field; fast path at top of `_index_file` (streamed 1 MiB sha256 → row lookup → `_unchanged_doc`); stamping on all three branches' create/update + shared helper; pre-phase NULL-hash fallthrough; retriever/`content_hash` untouched.
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → 3438 passed, `app/` coverage **99%** (>90% ✓); `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` → 3 passed in isolation ✓; `uv run ruff check . && uv run pyright` → all checks passed, 0 errors ✓; prior-phase `tests/e2e/test_sync_button.py` → 3 passed (no regression) ✓.
- **Completion criteria:** all 7 pass — (1) column + round-trip ✓ (2) unchanged text/image/garbage-PDF/pictorial-PDF cost zero LLM/extraction/vision, `summary.unchanged` counted, rows byte-still ✓ (3) changed file re-indexes + re-stamps; bytes-changed/content-same stamps only ✓ (4) backfill/D1/D4 + log wordings pinned verbatim ✓ (5) `content_hash`/retriever byte-identical ✓ (6) all gates green ✓ (7) no prior-phase behavior change ✓.
- **Documentation:** full review of README.md (1,031 lines) and AGENTS.md against the code: every `.env.example`/code `BOR_*` var documented, all config defaults match `app/config.py`, quick-start/QA commands and referenced test files exist, `file://` URL + troubleshooting claims accurate; the phase's README "Updating the documents" edit is accurate. **Verified consistent — no changes needed.**
- **Notable:** no live-infrastructure changes (dev Postgres container used for the standard test gates only). Working tree left uncommitted per harness protocol.
- **Next pending phase:** `148_pdf_pages_in_sources`.
Phase 146 final verification pass complete — all green, tree untouched by me.
**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)
**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓
**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).
**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).
**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.
**Next pending phase:** `147_sync_changed_only`.
**Phase 144 final verification pass — complete, 100% green.**
- **Verified:** all 3 tasks already implemented in-tree — `document-modal.js` history-aware modal (one state-only entry per open: push on open, adopt on forward re-open, in-place `replaceState(prev)` on button/Escape/backdrop close, `closingViaBack`-guarded back-close, top-level popstate listener), `tests/unit/test_frontend_doc_modal.py` source pins, `tests/e2e/test_modal_back_button.py` (4 tests). No code fixes needed.
- **Tests:** `uv run pytest` → 3371 passed · `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, **app/ 99%** (gate >90%) · `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed in isolation · regressions in isolation: `test_document_back_navigation.py` 3 passed, `test_kb_tree_nav.py` 4 passed · `uv run ruff check .` clean, `uv run pyright` 0 errors.
- **Completion criteria:** back closes modal (URL/view/drill untouched; second back navigates as usual) ✅ · back→forward re-opens same doc, no duplicate entry ✅ · button/Escape/backdrop close leaves no dead back step ✅ (state byte-identity + modal-free back chain; `replaceState` can't shrink `history.length` — documented deviation, locked design forbids `history.back()/go()`) · all gates green ✅ · no behavior change in completed phases (pre-phase pins + both regression E2Es green) ✅.
- **Docs:** README — one feature-description gap fixed (added the phase-144 Back/Forward modal behavior to the Chat section); all other README facts verified against code (routes, every `.env.example` var, all ~40 documented defaults vs `app/config.py`). AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no live-infrastructure work touched; all changes left uncommitted in the working tree per harness protocol.
- **Next pending phase:** `145_git_source_links`.
All verification complete. Final report:
**Phase 142 final verification pass — all green**
- **Verified implemented** (working tree matches design; app diff is exactly 2 lines of logic): `app/rag/overview.py` — `regenerate_overview` now orders the lite-model document list `created_at` desc, `(source, path)` tie-break (newest docs survive the cap); `app/rag/prompts.py` — `_KB_INTRO` rewritten to the honest intro (recency bias, "map not inventory", verify-with-`ls`/`grep`; "(generated at import time)" gone).
- **Completion criteria:**
1. Forced-cap outline leads with newest docs — ✅ E2E `test_kb_overview_freshness.py` test 1 (cap 300, digest from `zz_new/z.md`, no "old" tokens) + integration repro (cap 150 → `mydocs zz new 03 md zz new 03`)
2. New honest intro served in `<knowledge_base>` — ✅ unit pin (verbatim) + E2E test 2 (`build_kb_section` surface, tag/cap byte-stable)
3. Gates — ✅ `uv run pytest --cov=app --cov-report=term-missing`: **3359 passed**, `app/` TOTAL **99%** (>90%); `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov`: **3 passed** in isolation; `uv run pytest tests/e2e/test_kb_overview.py -v --no-cov`: **4 passed** in isolation; `uv run ruff check . && uv run pyright`: **clean, 0 errors**
4. No behavior change in completed phases — ✅ cap env knob, change-gated sync trigger, `build_kb_section` cap/marker, deflect path all byte-identical (pinned pins pass in the 3359)
- **Documentation:** README.md reviewed in full (every `.env.example` var documented with matching defaults, all endpoints/routes verified against `app/api/`, Quick Start/deploy/troubleshooting claims verified) and AGENTS.md re-reviewed (rules + quick reference accurate) — **verified consistent, no changes needed**.
- **Decisions:** none required — no defects found; no live-infra changes (only the already-running local `db` container was used by tests).
- **Next pending phase:** `.agents/phases/todo/143_mobile_keyboard_after_answer`.
All gates green and verified. Final report:
**Phase 141 final verification pass — complete**
- **Verified (tasks 01–04 already in `complete/`):** `AgentHolder.read_coverage` span recording (all 4 read outcomes, refusals record nothing); coverage dedupe matrix (range read of uncovered lines proceeds, fully-covered re-read → `ALREADY_IN_CONTEXT` with a now-true claim, partial plain re-read → `REREAD_PARTIAL` naming merged spans, `REREAD_TRUNCATED` retired, fast path retired per task 02); `TOOLS_SECTION` re-read sentence re-cut to coverage-true form (prompt lock re-pinned); owner-scenario integration repro (pure + truncated variants) and mock `read two ranges` flow + dedicated E2E — all match the task specs exactly; no code defects found.
- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL 99% (5498 stmts, `app/rag/agent.py` 99%) — run twice, both green; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` → 2 passed in isolation; `uv run ruff check . && uv run pyright` → All checks passed / 0 errors.
- **Completion criteria:** (1) two different ranges both reach the model, re-covered re-read refused truthfully — ✅ (integration + E2E); (2) partial plain re-read gets the `offset`/`limit` teaching, false claim gone both directions — ✅; (3) plain-read + phase-131 result shapes byte-identical, existing pins pass unchanged — ✅; (4) all gate commands green, >90% — ✅; (5) no behavior change in completed phases (grep/ls, truncation notice, `read_docs`/`read_truncations` signals) — ✅ (whole project suite green).
- **Documentation:** README.md — fixed pre-existing phase-140 gap: `BOR_PDF_PICTORIAL` / `BOR_PDF_PICTORIAL_MIN_CHARS` were in `.env.example`/Settings but undocumented (README claims every `.env.example` var is covered); added the pictorial-PDF-pass bullet to the PDF section + two config-reference rows. AGENTS.md — verified consistent (rules, quick-ref commands, phase refs all check out) — no changes needed.
- **Notable:** overview's "fast path survives for holder-state rows" note is superseded by task 02's authoritative decision (every dedupe row resolves — "full" needs the true line count); implementation follows task 02. No phase files touched; changes left in the working tree for the harness commit.
- **Next pending phase:** `142_kb_overview_freshness`.
Protocol B append (phases 146-149) for the four TODO items: the
search tool line missing the grep's document scope (146), sync-only-
what-changed via a raw-file byte hash checked before extraction and
vision (147), pictorial PDFs showing their rendered pages in the
sources viewer instead of page descriptions (148), and the sync
detail moved to a text box under the button with the button as a
spinner (149). TODO.md cleared (items now live in .agents/phases/
todo/).
LLM-Generated: true
Protocol B append (phases 141-145) for the five TODO items: the
read-range dedupe false "Already in context" bug (141), the stale
KB overview recency ordering + honest intro (142), the mobile
keyboard pop after answer completion (143), the document modal
back-button history entry (144), and the git original-source links
in answers + the doc modal (145). TODO.md cleared (items now live
in .agents/phases/todo/).
LLM-Generated: true
All verification is complete and green. Phase 139's three tasks (01–03) were already implemented in prior passes; this was the final verification pass, and every gate holds.
## Phase 139 — final verification report
**Verified in working tree (no code changes needed):**
- `readRangeSuffix` single-source-of-truth helper in `app.js` + byte-identical parity copy in `shared.js` (EN-dash ` (lines N–M)`, `(from line N)`, `(first N lines)`, `Number.isInteger(x) && x >= 1` guard mirroring the server's `_whole_line_number`)
- `appendToolLine(wrap, name, argument, offset = null, limit = null)` — `<span class="read-range">` sibling after `<code>`, only when suffix non-empty; `shared.js` `addToolLines` parity
- Live `tool` branch: guarded `ev.offset`/`ev.limit`, additive `toolAcc` records (non-range keeps pre-phase shape), status label carries the suffix (A2); restore path passes stored `t.offset`/`t.limit` through, phase-95 truncation note untouched
- E2E: `SAME_FILE_TRIGGER` mock flow (same file read twice, offsets 10/20 then 40/15) + 3-test Playwright suite
**Test / lint / coverage results:**
- `uv run pytest` (unit + integration): **3283 passed, 9 skipped, 0 failed**
- `uv run pytest --cov=app --cov-report=term-missing`: **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov`: **3 passed** in isolation
- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**
**Completion criteria:**
1. Range read renders `(lines N–M)` live; two same-file reads show DIFFERENT ranges — ✅ (E2E test 1)
2. Plain read byte-identical; phase-87/117 pins + emoji guard pass unedited — ✅ (E2E test 2 + full suite)
3. Suffix survives save → restore on chat page + shared page — ✅ (E2E test 3)
4. Full suite green, coverage >90%, E2E isolated green, ruff + pyright clean — ✅
5. Commit + phase move — left to the harness per executor rules (no `git add`/`commit` run)
**Notable:** the missing pytest summary line on one run was a transient output-buffering artifact, confirmed by re-run; DB was already up and healthy.
**Next pending phase:** `140_pictorial_pdf_pages`
All verification is complete — everything is green. No repo-side changes were needed in this pass (the phase's work in the working tree is correct and complete).
## Phase 133 final verification report
- **Verified (already implemented in working tree):** `is_usable_pdf_text` gate (0.30 XML-ish / 0.05 non-printable / empty), `render_pdf_pages`, `_fallback_page_screenshots` + `pdfs_screenshotted` counter in `app/rag/{pdf,importer}.py`; regression pin, unit/integration/E2E suites.
- **Completion criteria:**
1. Real guide clean through real pipeline — ✅ `test_pdf_hnb_guide_regression` + E2E preview/viewer checks (0 XML-ish lines, first-page text present).
2. Garbage PDF → vision fallback end to end — ✅ unit + E2E (stored content = canned per-page descriptions, garbage not stored, sync `detail` shape unchanged).
3. Clean PDF never burns a fallback vision call — ✅ unit + E2E (byte-faithful content).
4. Gates — ✅ `uv run pytest --cov=app --cov-report=term-missing`: **3129 passed, app/ 99%** (>90%); `uv run pytest tests/e2e/test_garbage_pdf.py -v --no-cov`: **3 passed** in isolation.
5. No behavior change to completed phases — ✅ entire suite green (all prior-phase tests included); `ruff check .` + `pyright` clean.
6. Production stale row — post-deploy re-sync verification (live infra, no repo work; no live changes made — deploy of `main` is the owner's step).
- **Documentation:** README.md reviewed in full — every env default cross-checked against `app/config.py`, page routes against `app/main.py`, scripts/Containerfile/compose flags, `.env.example`; the phase-133 PDF bullet added in the working tree is accurate. AGENTS.md verified consistent (all referenced files/commands exist). **No changes needed.**
- **Next pending phase:** `134_sync_cancel`.
All 44 progress lines are pure dots, final line at 100%, exit 0. Everything is verified. Final report:
**Phase 131 — final verification pass (all 4 tasks already complete in working tree)**
- **Verified implemented:** `read(offset, limit)` schema + teaching descriptions; rewritten truncation notice; full 6-row dedupe matrix + range validation + `lines: a-b of N` header + slice cap in `_execute_tool`; wire-level `offset`/`limit` on `ChatToolEvent` (absent keys dropped → non-range frames byte-identical); mock-LLM `read the lines` flow; handbook repro (wording + line numbers derived from the real PDF at test time)
- **Tests:** `uv run pytest --no-cov` → 3097 passed, exit 0; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov` → 2/2 in isolation (47.6 s); `tests/integration/test_read_line_ranges_handbook.py` → 2/2 (3.5 s); 10-file targeted E2E batch pinning pre-phase surfaces (grep/ls/plain-read/ALREADY_IN_CONTEXT/truncation) → 31/31; `ruff check .` clean; `pyright` 0 errors
- **Completion criteria:** all 5 green — (1) exact range lines + `lines:` header, plain reads byte-identical (existing pins pass unchanged) ✅ (2) truncated-then-range succeeds end-to-end on the real handbook; other re-reads get teaching refusals ✅ (3) find-traps answered from real PDF text, no hardcoded wording ✅ (4) coverage + isolated E2E ✅ (5) no behavior change in completed phases ✅
- **Docs:** README + AGENTS reviewed in full. Fixed 3 inconsistencies: README missing `BOR_HISTORY_MAX_TURNS`/`BOR_HISTORY_MAX_CHARS` rows (pre-existing phase-74 gap — env-tunable, unit-pinned, undocumented); `.env.example` missing the same two vars (added); `.env.example` stale `BOR_READ_MAX_CHARS` comment (still said "grep-pointer notice (phase 95)" — updated to the phase-131 workflow notice). AGENTS.md verified consistent — no changes needed. All 67 `.env.example` vars documented, all defaults match `app/config.py`, pages/auth tables match live routes
- **Note:** PLAN.md §7.1 still states the retired 46rem chat column (superseded by phase 100, which AGENTS.md rule 5 records correctly) — PLAN.md is off-limits for this pass, flagged only
- **Next pending phase:** `132_image_upload_feedback`
Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report:
**Phase 128 final pass — verified (all 3 tasks complete; no code defects found)**
- Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1.
- `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate).
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated).
- `uv run ruff check .`: clean; `uv run pyright`: 0 errors.
- Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅.
- Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes.
- Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green.
- Next pending phase: `129_sync_chunk_progress`.
2026-09-27 23:58:57 -04:00
2036 changed files with 231395 additions and 4150 deletions
@@ -59,6 +59,7 @@ accounts (one admin + hand-out tokens is the model).
| A18 | Docs push | Save-a-answer-as-doc: server-side draft (`doc_drafts`, long body never in a URL — unguessable `uuid4` token is the URL credential) pushed to a **generic git remote** (`BOR_DOCS_REPO`, URL or local path; no `gh`) on a dedicated branch, `--ff-only`, re-cut per push; **no PR tooling**; inert (hidden + 409) while the repo is unset |
| A19 | Deploys & caching | HTML pages `Cache-Control: no-cache` (no etag/last-modified); `/assets/*` versioned (`?v=<token>`) + `immutable, max-age=1y`; **the token is the deploy** (git `HEAD` short SHA; content-hash fallback) — a deploy is a commit the browser sees without a hard refresh |
| A20 | Security headers | Every response: `Content-Security-Policy: default-src 'self'` (+ the theme's inline-`<style>` hash on themed pages), `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`; login rate-limited 10 attempts / 15 min (in-memory, A12) |
| A21 | LLM call economics | **ALL LLM calls are free** (chat, vision, embed, `lite` — self-hosted aipi): the sync/import path may burn as many LLM/vision calls as quality requires — e.g. render + describe **every** sparse page of a pictorial PDF, no page caps, no call budgets (owner 2026-09-30). Latency targets bind the **interactive** path only (retrieval, inference, tool calling — the inference engine's job, not ours to micro-optimize): never cap, throttle, or skip work to save calls; a slower sync is acceptable, a lower-quality KB is not |
**UI/theming anchors (B):**
@@ -70,14 +71,38 @@ accounts (one admin + hand-out tokens is the model).
| B5 | Admin-only nav views (Sources, Git sources, Tuning, History, Tokens, Theme) are hidden from non-admins; a monochrome theme must keep every state **text** label ("text + color, never color alone") |
**Key recent revisions (full log in the previous plan revision / phase
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`frontend/assets/markdown.js` — the shared escape-first renderer (loaded by all three pages: `index.html` chat, `document.html` viewer, `shared.html`): step 1 fence protection (`\u0000CODEn\u0000`), step 1b table protection (`\u0000TABLEn\u0000`, phase 44), step 2 `inline(escapeHtml(text))` (the inline chain: code → strong → em → h3/h4 → ul → ol), step 3 the paragraph pass (split on `\n{2,}`; the block-element guard `/^<(h\d|ul|ol|pre|li)/`), step 4 restore. Today a `>` line is escaped to `>` and renders as literal text in a `<p>` (verified: `renderMarkdown("> quoted")` → `<p>> quoted</p>`). `frontend/assets/styles.css` — the `:root` palette (`--ink-soft` 8.2:1 on `--surface`, `--line`, the `--brand-*` family; the `:root`-drift unit test parses the built-ins from this file — no second copy) and the chat-bubble / viewer typography. `tests/unit/test_markdown_tables.py` — the frontend source-pin + node-behavior unit pattern (`HAVE_NODE` skip); `tests/e2e/test_markdown_tables.py` — the phase-44 E2E pattern (mock flow with table output + the document viewer).
## Objective
`>`-prefixed lines in any rendered markdown — a chat answer, the thinking block, a saved/shared chat (restore), the document viewer — render as a semantic `<blockquote class="md-quote">` with theme-safe styling; every non-quote input renders byte-identical to today (the phase-44 table suite and the renderer-dependent unit suites stay green unedited).
## Dependencies
-`137_pdf_image_context` (complete) — pipeline order only; the code dependencies are the shared renderer + `styles.css` on `main`.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The pass (task 01):** a new line-block pass in `markdown.js` BETWEEN step 2 (`inline(escapeHtml(text))`) and step 3 (paragraphs) — at that point the text is escaped (a quote line starts with `>`) and already inline-transformed (bold/emphasis/code work inside quotes for free — the pass never re-transforms). Group the maximal run of consecutive lines matching `^\s*>+ ?` into ONE `<blockquote class="md-quote">…</blockquote>`: strip the leading `>` run plus ONE optional space from each member (nested `>>` / `>>>` collapses to one level — ASSUMPTION A1), join members with `<br>` (the paragraph convention). A BLANK line terminates the group (a re-quoted line starts a new `<blockquote>` — simple, predictable). The step-3 guard extends to `/^<(h\d|ul|ol|pre|li|blockquote)/` so the paragraph pass leaves the block alone (no `<p>` wrap).
- **Precedence is untouched:** fences protect first (step 1 — a `>` inside a code block never reaches the pass), tables protect second (step 1b — cell content is escaped inside the `<td>` markup and restored in step 4, AFTER the pass). No other pass changes.
- **In-quote structure (documented limit):** a markdown STRUCTURE inside a quote (a `- list` line, a `# heading` line) renders as plain text inside the blockquote — the inline chain runs before the pass, so its line-start transforms never see the post-strip text. Inline (code/strong/em) still works. Acceptable — the scope is quote rendering (owner L3).
- **The CSS (task 02):** `.md-quote` in `styles.css` — theme-safe from the EXISTING `:root` palette only: left border + horizontal padding + vertical margin + a muted text color carrying a ≥4.5:1 contrast comment on the bubble surface (the house comment convention, cf. `--ink-soft`'s `/* 8.2:1 on --surface */`). ONE rule set serves all three contexts (chat `.bubble`, `.thinking-text`, the document viewer) — a scoped override only if the E2E proves a clash.
- **Byte-identity (task 03):** any input without a quote line renders byte-identical to the pre-phase renderer — pinned by the node behavior check and by the unedited `test_markdown_tables.py` / `test_thinking_scroll.py` / `test_document_viewer.py` suites.
- **E2E (task 03):** dedicated `tests/e2e/test_markdown_blockquotes.py`, run in isolation (AGENTS.md rules 4/9), mock-only.
## Tasks
1.`01_blockquote_pass.md` — the blockquote pass in `markdown.js` + the paragraph-guard extension.
2.`02_blockquote_css.md` — the `.md-quote` styling in `styles.css` (theme-safe, WCAG AA contrast).
3.`03_unit_and_e2e.md` — unit source-pins + node behavior + the dedicated Playwright E2E.
## Testing & Quality
- Unit: `tests/unit/test_markdown_blockquotes.py` (new — the phase-44 pattern): source pins (pass order fences → tables → escape+inline → **blockquotes** → paragraphs → restore; the guard regex includes `blockquote`; the exact `<blockquote class="md-quote">` marker; the `.md-quote` CSS rule references palette vars only) + node behavior (single-line quote, multi-line group, nested collapse, XSS quote inert, fence-wins, a table cell containing `>` untouched, non-quote input byte-identical).
- E2E: `tests/e2e/test_markdown_blockquotes.py` (new), in isolation.
- Coverage: **>90%** on `app/` (the gate runs the full suite — this phase adds no `app/` code, so the suite staying green IS the gate).
## Completion Criteria
- [ ]`>` quotes render as a styled `<blockquote class="md-quote">` in the chat answer, the thinking block, saved/shared chats (restore), and the document viewer
- [ ] nested `>>` collapses to one level; a `>` inside a code fence or a table cell is untouched
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] one `--no-gpg-sign` Conventional Commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate
## Locked decisions
- **A1 — nested quotes (`>>`, `>>>`) collapse to a single `<blockquote>` level (owner-confirmed 2026-09-30, roadmap confirmation).**
- **A2 — the quote style is a left border + padding + muted text color (NO background fill), built from the existing `:root` palette only (owner-confirmed 2026-09-30, roadmap confirmation).**
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(ui): render markdown blockquotes in chat, viewer, and shared pages"
`renderMarkdown` turns runs of `>`-prefixed lines into `<blockquote class="md-quote">` elements — after the escape + inline step, before the paragraph pass — with fences and tables keeping their precedence and non-quote input byte-identical.
## Work
1.`frontend/assets/markdown.js` — new pass between the step-2 `text = inline(escapeHtml(text))` and the step-3 paragraph `.map(...)`: split the text on `\n`; group the maximal run of consecutive lines matching `^\s*>+ ?` (the escaped quote marker); strip the `>` run + one optional space from each member (nested `>>`/`>>>` collapses to one level — ASSUMPTION A1); join members with `<br>`; emit `<blockquote class="md-quote">…</blockquote>`. A blank line terminates the group.
2.`frontend/assets/markdown.js` — extend the step-3 block-element guard from `/^<(h\d|ul|ol|pre|li)/` to `/^<(h\d|ul|ol|pre|li|blockquote)/` so the paragraph pass leaves the block alone (no `<p>` wrap).
3.`frontend/assets/markdown.js` — update the module docstring: pass order is now fences → tables → escape+inline → **blockquotes** → paragraphs → restore; document the group rule (blank line terminates; nested collapses) and the documented limit (a list/heading line INSIDE a quote renders as plain text — the inline chain ran first; inline code/strong/em work).
- ASSUMPTION: a `>` inside a code fence is already protected by the step-1 placeholder (no handling needed); a `>` in a table cell is escaped inside the `<td>` markup and restored in step 4 — both are pinned in task 03's behavior checks.
## Testing & Quality
- Unit: covered by task 03's file (the source pin + the node behavior listed there) — the frontend house pattern is one unit file per feature.
- Coverage: n/a (frontend) — the suite + the pins are the gate.
## Completion Criteria
- [ ] node repro: `renderMarkdown("> a\n> b")` → `<blockquote class="md-quote">a<br>b</blockquote>`; `renderMarkdown("> quoted")` no longer emits a literal `>`
- [ ] a non-quote input (plain paragraph, list, table, fence) renders byte-identical to the pre-task renderer
- [ ]`uv run pytest` green (the renderer-dependent unit suites unedited); `uv run ruff check .` clean
The blockquote is visually distinct and readable in all three rendering contexts (chat bubble, thinking block, document viewer) with ONE rule set, using only the existing `:root` palette.
## Work
1.`frontend/assets/styles.css` — add the `.md-quote` rule next to the other markdown element styles (the phase-44 `.md-table` / `.md-table-wrap` block is the anchor): `border-left: 3px solid <palette var>` (a visible-but-restrained border — pick from the existing `:root` palette, the executor's contrast judgment), `padding: 0.25rem 0.75rem`, `margin: 0.5rem 0`, `color: <muted palette var>` with a ≥4.5:1 contrast comment on the bubble surface per the house convention (`--ink-soft`'s `/* 8.2:1 on --surface */` qualifies — verify against the actual bubble background and document it).
2.`frontend/assets/styles.css` — no background fill (ASSUMPTION A2); verify the rule renders acceptably in the chat `.bubble`, the `.thinking-text` block, and the document-viewer content region — add a scoped override only if the task-03 E2E proves a clash.
- ASSUMPTION: the built-in dark palette is the single source of truth — the `:root`-drift unit test parses built-ins from this file, so no hex literal outside `:root` and no second copy.
## Testing & Quality
- Unit: `tests/unit/test_markdown_blockquotes.py` (task 03) source-pins the `.md-quote` rule's presence and its palette-var-only references (the phase-44 CSS-pin pattern).
- Coverage: n/a (frontend).
## Completion Criteria
- [ ] the `.md-quote` rule exists in `styles.css`, references only `:root` palette vars, and carries a ≥4.5:1 contrast comment
- [ ] the quote is visually distinct in chat + viewer (the E2E screenshot, task 03)
- [ ] the theme no-op (B4) is unaffected — the rule uses existing vars; no new `ui_settings` column
Pin the renderer contract at the source level, prove the browser behavior, and ship the dedicated Playwright file, run in isolation (AGENTS.md rules 4/9).
## Work
1.`tests/unit/test_markdown_blockquotes.py` (new) — the phase-44 pattern (source pins + a `HAVE_NODE`-skipped node behavior):
- Source pins: the blockquote pass runs after `inline(escapeHtml(text))` and before the paragraph split; the paragraph guard includes `blockquote`; the exact `<blockquote class="md-quote">` marker string; the `.md-quote` CSS rule references palette vars only (the task-02 pin).
- Node behavior: single-line quote → one blockquote; a 3-line group → one blockquote with `<br>` joins; `>> nested` collapses to one level; `> <script>alert(1)</script>` renders inert (no live tag in the output); a `>` inside a ``` fence stays fence content; a table cell containing `> x` renders as table-cell text, not a blockquote; non-quote input (a paragraph + list + table) renders byte-identical to the pre-phase renderer (fixture string checked into the test, per the phase-44 shape).
2.`tests/e2e/test_markdown_blockquotes.py` (new) — dedicated, run in isolation (`--no-cov`, DB up), mock-only, following `tests/e2e/test_markdown_tables.py`'s structure:
- (a) the chat: a mock flow whose answer contains `>` quotes (the phase-44 mock-flow pattern — a new user-message marker, a deterministic canned answer) → assert the DOM: the `.bubble` contains a `blockquote.md-quote` with the quote text and no literal `>` text at the block's start;
- (b) the viewer: a KB fixture with a `.md` document containing a quote block → open the document → assert the viewer content renders the `blockquote.md-quote`;
- (c) the restore: save the chat (or reload `/?chat=<id>`) → the restored bubble keeps the blockquote (the restore path runs the same renderer — a cheap assertion if the suite structure allows it).
## Testing & Quality
- Unit: as above. E2E: as above. Coverage: **>90%** on `app/` (the gate runs the full suite; frontend changes add no `app/` code — the suite must stay green).
## Completion Criteria
- [ ]`uv run pytest tests/unit/test_markdown_blockquotes.py -v` green (node available → the behavior check runs)
- [ ]`uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` green in isolation
- [ ]`uv run pytest` green, coverage TOTAL >90%, `uv run ruff check . && uv run pyright` clean
# Phase 139 — The Reading tool line shows which lines are being read
**Source:**`TODO.md` L4 — "The \"Reading\" tool UI output doesn't show what lines are being read, just the same file over and over which is confusing to the user"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`app/api/chat.py` — the SSE `tool` frame (`ChatToolEvent`) ALREADY carries `offset` / `limit` for a range `read` (phase 131 task 04 — the fields land on the frame only when well-formed via `_whole_line_number` (L307) and are omitted otherwise, so non-range frames are byte-identical); the frame is emitted at ~L928-952. `frontend/assets/app.js` — `appendToolLine(wrap, name, argument)` (L1119 — the read branch builds `📄 Reading ` + a `<code>{argument}</code>` child; the line bytes are pinned by the phase-87 emoji guard + the phase-117 disclosure pins), the live `tool` branch (L3103-3137 — reads only `ev.name` / `ev.argument`; `toolAcc.push({ name, argument })` L3116; the status label `is reading ${argument}` L3125), `toolAcc` (L2993 — the per-turn accumulator persisted as the saved chat's `tools` array at L3229 / L3310 / L3361 / L3395), the restore path (~L2030-2045 — `m.tools` → `appendToolLine(wrap, t.name, arg)` + the phase-95 truncation marker). `frontend/assets/shared.js` — `addToolLines(wrap, tools)` (~L175+ — the shared page's parity copy of `appendToolLine` with the pinned-identical line literals). `tests/integration/test_api.py` — the frontend emoji guard strips precisely the pinned emoji literals from `app.js` / `shared.js` (no new emoji may be introduced). `tests/e2e/test_read_line_ranges.py` + the phase-131 mock flow (`read the lines` marker → grep → `read(offset, limit)`) in `tests/e2e/mock_llm.py`.
## Objective
When the agent range-reads a document (`read(path, offset, limit)` — phase 131), the Reading tool line shows WHICH lines — `📄 Reading <code>source/path</code> (lines 42–71)` — so repeated reads of the same file are distinguishable, live, on restore, and on the shared page; a plain read renders byte-identical to today.
## Dependencies
-`138_markdown_blockquotes` (todo) — pipeline order only; no code overlap (139 touches the tool-line rendering, 138 the markdown renderer).
- Code: the phase-131 wire fields (complete) — nothing new server-side.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The suffix (task 01):** one new helper in `app.js` — `readRangeSuffix(offset, limit) → string | ""` — the single source of truth for the wording (used by both the tool line and the status label): both present → ` (lines {offset}–{offset + limit - 1})` (EN-DASH; the pinned copy — ASSUMPTION A1: the range shown is the model's REQUEST; the UI does not know the document length, so a `limit` past EOF renders the requested extent); offset only → ` (from line {offset})`; limit only → ` (first {limit} lines)`; neither / malformed → `""`. The JS well-formedness guard mirrors the server's `_whole_line_number`: `Number.isInteger(x) && x >= 1`.
- **`appendToolLine` (task 01):** the signature becomes `appendToolLine(wrap, name, argument, offset = null, limit = null)` — the read arm (`read` / `read_document`) appends, AFTER the `<code>` child, a `<span class="read-range">` with `textContent = readRangeSuffix(offset, limit)` ONLY when the suffix is non-empty (a plain read's line stays byte-identical — the phase-87/117 pins + the emoji guard stay green; no new emoji). `shared.js`'s `addToolLines` applies the identical suffix to its pinned-identical line (the parity contract — the shared page re-renders saved records that now carry the fields).
- **The live branch (task 02):** read `ev.offset` / `ev.limit` with the same guard (absent / malformed → `null`); `toolAcc.push({ name, argument, offset, limit })` with the fields included ONLY when non-null (a non-range record keeps the exact pre-phase `{name, argument}` shape — the `bor.chat.v1` save JSONB is additive; old records without the fields restore byte-identically, no migration); pass both to `appendToolLine`; the status label becomes `${brand()} is reading ${argument}${suffix}` (same helper — ASSUMPTION A2).
- **The restore path (task 02):** `t.offset` / `t.limit` (same guard) → `appendToolLine(wrap, t.name, arg, tOffset, tLimit)`; the phase-95 truncation note is untouched (it keys on the `<code>` argument — verify with the existing pins).
- **E2E (task 03):** ONE new mock flow following the phase-131 pattern (user-message marker, deterministic replies, module-header docstring entry): the model range-reads the SAME document twice with different `offset`/`limit` — the owner's exact "same file over and over" scenario.
## Tasks
1.`01_tool_line_range_suffix.md` — `readRangeSuffix` + the `appendToolLine` / `addToolLines` suffix rendering.
2.`02_live_restore_persist.md` — the live branch's `ev.offset`/`ev.limit`, the `toolAcc` persistence, the restore path, the status label.
3.`03_unit_and_e2e.md` — unit pins + node behavior + the mock flow + the dedicated Playwright E2E.
## Testing & Quality
- Unit: `tests/unit/test_read_range_tool_lines.py` (new — the `test_frontend_tool_states.py` pattern): source pins (the suffix template literals, the `Number.isInteger` guard, the `ev.offset`/`ev.limit` read, the `toolAcc` push carrying the fields, the restore pass-through, the shared.js parity) + node behavior (`appendToolLine` with a range vs without — the plain line byte-identical; the three suffix shapes).
- E2E: `tests/e2e/test_read_range_tool_lines.py` (new), in isolation.
- Coverage: **>90%** on `app/` (no `app/` code changes — the suite staying green IS the gate).
## Completion Criteria
- [ ] a range read renders `📄 Reading <code>path</code> (lines N–M)` live; two range reads of the same file show DIFFERENT ranges (the owner's confusion is gone)
- [ ] a plain read's line is byte-identical to pre-phase (the phase-87/117 pins + the emoji guard pass unedited)
- [ ] the suffix survives save → restore (the chat page + the shared page render it from the saved record)
- [ ]`uv run pytest` green; coverage TOTAL >90%; `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] one `--no-gpg-sign` Conventional Commit; phase dir moved to `.agents/phases/complete/`
## Locked decisions
- **A1 — the suffix shows the model's REQUESTED range (`offset`…`offset+limit−1`); the UI cannot know the actual slice extent (owner-confirmed 2026-09-30, roadmap confirmation).**
- **A2 — the status label carries the same suffix (`Brain is reading source/path (lines 42–71)`) (owner-confirmed 2026-09-30, roadmap confirmation).**
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(ui): show the read tool's line range in the Reading line"
# Task 01 — The range suffix on the Reading line (chat + shared page)
**Phase:**`139_read_range_tool_lines` · **Source:**`TODO.md:4` — "The \"Reading\" tool UI output doesn't show what lines are being read, just the same file over and over which is confusing to the user"
**Story:** n/a
## Objective
`appendToolLine` (and the shared page's `addToolLines`) render the requested line range as a suffix on the Reading line whenever a well-formed `offset`/`limit` is passed — createElement + textContent only, plain reads byte-identical.
## Work
1.`frontend/assets/app.js` — add a `readRangeSuffix(offset, limit)` helper next to `appendToolLine`: the well-formedness guard `Number.isInteger(x) && x >= 1` (mirrors the server's `_whole_line_number`); both present → ` (lines ${offset}–${offset + limit - 1})` (en-dash — the pinned copy); offset only → ` (from line ${offset})`; limit only → ` (first ${limit} lines)`; otherwise → `""`.
2.`frontend/assets/app.js` — `appendToolLine(wrap, name, argument, offset = null, limit = null)`: in the read arm (`read` / `read_document`), compute `readRangeSuffix(offset, limit)`; when non-empty, append a `<span class="read-range">` (textContent = the suffix) AFTER the `<code>` child. No other arm changes; no new emoji (the guard in `tests/integration/test_api.py` strips precisely the pinned literals).
3.`frontend/assets/shared.js` — `addToolLines`: the same suffix on the shared page's pinned-identical Reading line (the saved record carries the fields from task 02; a record without them renders exactly as today).
- ASSUMPTION: the span is a SIBLING of the `<code>` child — the phase-95 truncation note is the precedent (it appends a span sibling and never rewrites the line's pinned template text).
- [ ] node repro: `appendToolLine` with `offset=42, limit=30` → the line's text is `📄 Reading <path> (lines 42–71)`; without the arguments → byte-identical to pre-task
- [ ]`uv run pytest` green (the phase-87/117 pins + the emoji guard unedited); `uv run ruff check .` clean
# Task 02 — Wire the range through live frames, persistence, and restore
**Phase:**`139_read_range_tool_lines` · **Source:**`TODO.md:4` — "The \"Reading\" tool UI output doesn't show what lines are being read, just the same file over and over which is confusing to the user"
**Story:** n/a
## Objective
The `offset`/`limit` already on the SSE `tool` frame (phase 131) reach the Reading line live, persist in the saved chat's `tools` record (additively), and re-render on restore — chat page + shared page.
## Work
1.`frontend/assets/app.js` — the live `tool` branch (~L3103): read `ev.offset` / `ev.limit` (the `Number.isInteger(x) && x >= 1` guard → `null` when absent / malformed); `toolAcc.push({ name, argument, offset, limit })` with the fields included ONLY when non-null (a non-range record keeps the exact pre-phase `{name, argument}` shape — the `bor.chat.v1` save JSONB is additive; old rows restore unchanged, no migration); pass both values to `appendToolLine`.
2.`frontend/assets/app.js` — the status label (~L3125): `${brand()} is reading ${argument}${readRangeSuffix(offset, limit)}` (the grep / ls labels are untouched).
3.`frontend/assets/app.js` — the restore path (~L2030): pass `t.offset` / `t.limit` (same guard) to `appendToolLine`; the phase-95 `appendTruncatedNote` call is untouched (it keys on the `<code>` argument).
4.`frontend/assets/app.js` — the save-payload sites (L3229 / L3310 / L3361 / L3395) need no code change (they serialize `toolAcc` as-is) — verify the phase-83 save-payload-limit tests are unaffected by the two extra fields.
## Testing & Quality
- Unit: task 03's file (source pins for the `ev.offset`/`ev.limit` read, the `toolAcc` push shape, the restore pass-through).
- Coverage: n/a (frontend).
## Completion Criteria
- [ ] a live range read: the line shows the suffix AND the status label shows it AND `toolAcc` carries `{name, argument, offset, limit}`
- [ ] a saved chat restored (the `/?chat=<id>` path) + the shared page re-render the suffix; a pre-phase saved record (no fields) renders byte-identical
- [ ]`uv run pytest` green (the chat-persistence / save-payload suites unedited)
**Phase:**`139_read_range_tool_lines` · **Source:**`TODO.md:4` — "The \"Reading\" tool UI output doesn't show what lines are being read, just the same file over and over which is confusing to the user"
**Story:** n/a
## Objective
Pin the suffix contract at the source level, add the owner's "same file over and over" scenario to the deterministic mock, and prove it end-to-end in a dedicated Playwright file, run in isolation.
## Work
1.`tests/unit/test_read_range_tool_lines.py` (new) — the `test_frontend_tool_states.py` pattern: source pins (the ` (lines ${offset}–${offset + limit - 1})` / ` (from line …)` / ` (first … lines)` literals; the `Number.isInteger` guard; the live branch's `ev.offset` / `ev.limit` read; the `toolAcc` push carrying the fields; the restore pass-through; the shared.js identical literals) + node behavior (the three suffix shapes; the plain-read line byte-identical to pre-phase; the shared-page parity).
2.`tests/e2e/mock_llm.py` — ONE new scripted flow (house style — a user-message marker, deterministic replies, a module-header docstring entry): the marker `read the same file twice` → round 1: a `read` tool call `(path, offset=10, limit=20)`; round 2: a `read` tool call `(path, offset=40, limit=15)`; round 3: the answer echoing both results (the phase-131 read-echo shape).
3.`tests/e2e/test_read_range_tool_lines.py` (new) — dedicated, run in isolation (`--no-cov`, DB up), mock-only; a KB fixture with ONE multi-hundred-line document:
- (a) the marker question → assert TWO Reading lines for the SAME `<code>` path carrying the distinct suffixes ` (lines 10–29)` and ` (lines 40–54)`;
- (b) a plain-read flow (an existing mock flow with a `read` call and no range — e.g. the phase-131 plain-read case or the phase-37 flow) → assert the Reading line has NO `.read-range` span (byte-identical shape);
- (c) save the chat → restore (reload `/?chat=<id>`) → assert the suffixes re-render from the saved record.
## Testing & Quality
- Unit: as above. E2E: as above. Coverage: **>90%** on `app/` (the gate; no `app/` changes).
## Completion Criteria
- [ ]`uv run pytest tests/unit/test_read_range_tool_lines.py -v` green
- [ ]`uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov` green in isolation
- [ ]`uv run pytest` green, coverage TOTAL >90%, `uv run ruff check . && uv run pyright` clean
# Phase 140 — Pictorial PDFs: sparse pages are rendered and vision-described into the indexed content
**Source:**`TODO.md` L5 — "I've added a new \"ikea_instructions.tar\" to test_sources. This is an example pdf that stil isn't working. There isn't much text, it's mostly graphical, but it doesn't have pictures. Take a screenshot of the first page or pages 8 onward, it's mostly pictographic illustrations of how to assemble a piece of furniture. We need to support this kind of pdf, and in this case showing rendered pages of the pdf is more helpful than the extracted text. We need a way to determine when that's neccesary. My suggestion is to screenshot the pdf, generate a text summary, compare it to the text extraction, and if the screenshot contains more information (you can use an LLM to judge) use the screenshot or rendered page rather in addition to the extracted text. More information is better when generating the text embeddings since that increases the chance the user's search will hit something."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** the owner's fixture `test_sources/ikea_instructions.tar` (gitignored, on disk — the phase-133 convention: tests extract to `tmp_path`, never the repo): `besta-frame-white__AA-1272068-11-100.pdf`, 24 pages, metadata title "Assembly Instructions" (Adobe InDesign) — pages 2–5 are text-heavy (multilingual warnings, 3,091–7,288 chars), the other 20 pages are sparse (4–73 chars, `Illustration N` markers) and carry **vector-drawn** pictograms (PyMuPDF `get_image_info()` → 0 embedded raster images — the phase-122/137 embedded-image pipeline sees nothing). Today the file passes the phase-133 garbage gate (`is_usable_pdf_text` — the text is usable, just thin) and is indexed as ~24 KB of page markers → a question about the assembly steps never hits. `app/rag/pdf.py` — `PAGE_SEPARATOR = "---PAGE-{page}---"` (L57), `extract_pdf_text` (L153), `extract_pdf_page_texts` (L178 — the phase-137 `dict[int, str]` helper), `is_usable_pdf_text` (L225 — the phase-133 gate), `render_pdf_pages` (L267 — all pages at 150 dpi, the zero-byte unreadable-page placeholder), `extract_pdf_images` (L300). `app/rag/importer.py` — `_index_pdf_file` (L1140 — extract → the phase-133 gate + `_fallback_page_screenshots` → digest over the FINAL content → upsert + the unchanged early-return → chunk + embed → `lite` summary → embedded images), `ImportSummary` (L212 — stats counters + the `log()` line; stats are LOG-ONLY, the sync `detail` API shape never changes). `app/rag/summarizer.py` — the marker convention (L36-95: `IMAGE_DESCRIPTION_MODE` / `DESCRIBE_PROMPT`), `describe_image` (L208 — ONE chat-model vision call, the multimodal user message, the `context` param, fail-soft `str | None`, no app-level retry). `app/config.py` — the toggle pattern (`images: bool = False` L379 — `BOR_IMAGES`; the house kill-switch: `0` disables, a negative value fails startup loudly). `tests/e2e/mock_llm.py` — the `IMAGE_DESCRIPTION_MODE` branch (L2685) + the canned reply + the phase-137 trigger pattern (a new marker branch is additive — the existing suites stay green unedited). `tests/e2e/test_pdf_support.py` — the synthetic-PDF fixture recipe (PyMuPDF build) + the module-app pattern.
## Objective
A PDF whose pages are mostly pictorial (the IKEA assembly-instruction class) indexes with high-quality content: every sparse page (deterministic gate: stripped text below a threshold) is rendered to a screenshot and vision-described, and the description is appended to that page's content block — so the embeddings carry what the text layer couldn't and search hits the illustrations (the owner's L5 rationale: "More information is better when generating the text embeddings"). NO LLM judge (owner decision 2026-09-30) and NO page cap (PLAN A21 — the LLM calls are free; a slower sync is acceptable, a lower-quality KB is not).
## Dependencies
-`139_read_range_tool_lines` (todo) — pipeline order only; no code overlap (140 is backend import + the mock; 139 is the frontend tool line).
- Code (all complete): phase 126 (the PDF pipeline), 133 (the garbage gate + `render_pdf_pages`), 137 (`extract_pdf_page_texts` + the vision seam).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The gate (task 01):** `app/rag/pdf.py` — `pictorial_pages(page_texts: dict[int, str], *, min_chars: int) -> list[int]`: the 1-based page numbers whose `len(text.strip()) < min_chars`, ascending — pure, deterministic, NO LLM call (ASSUMPTION A1). IKEA page 1 (`BESTÅ`, 5 chars) IS a candidate — a title page; describing it is useful (the logo + product name) and never harmful.
- **The settings (task 01):** `app/config.py` — `pdf_pictorial: bool = True` (`BOR_PDF_PICTORIAL`, the house kill-switch — `0`/`false` disables the pass entirely → content byte-identical to pre-phase; the `BOR_IMAGES` naming pattern) + `pdf_pictorial_min_chars: int = 100` (`BOR_PDF_PICTORIAL_MIN_CHARS`; validator: ≥ 0 — 0 = every page is a candidate (A21), negative fails startup loudly, the house pattern).
- **The vision seam (task 02):** `app/rag/summarizer.py` — `PICTORIAL_PAGE_MODE = "PICTORIAL_PAGE_MODE"` + `PICTORIAL_INSTRUCTION` (locked wording: describe in detail what the page shows — the assembly steps, part names, arrows, tool icons, before/after states — ESPECIALLY the information the extracted text does not contain) + `PICTORIAL_PROMPT = f"{PICTORIAL_PAGE_MODE}: {PICTORIAL_INSTRUCTION}"`; `describe_pdf_page(llm, *, data, mime, page, page_text) -> str | None` — the same one-chat-call multimodal contract as `describe_image` (no system prompt, no tools, no app-level retry, fail-soft `None`); the message text part = `PICTORIAL_PROMPT` + `\n\nPage {page} of the PDF. Text extracted from this page:\n{page_text.strip() or "(none)"}`. `DESCRIBE_PROMPT` / `IMAGE_DESCRIPTION_MODE` stay BYTE-IDENTICAL (the phase-122/126/137 suites pass unedited).
- **The render selector (task 02):** `app/rag/pdf.py` — `render_pdf_pages(pdf_path, *, dpi=150, pages: Sequence[int] | None = None)`: `pages` (1-based) restricts rendering to the named pages in ascending order (a 500-page PDF with 30 sparse pages renders 30 PNGs, not 500); `None` = all pages — the phase-133 caller stays byte-identical.
- **The import pass (task 03):** `_index_pdf_file` — AFTER the phase-133 gate (the precedence is locked: an unusable text layer takes the existing full-page screenshot fallback — NO pictorial pass, no double vision), when `settings.pdf_pictorial` and the content is usable:
1.`page_texts = extract_pdf_page_texts(full_path)`; `candidates = pictorial_pages(page_texts, min_chars=settings.pdf_pictorial_min_chars)` — **ALL candidates, no cap (A21)**; zero candidates → skip entirely (no render, no vision call — a clean-text PDF's path is byte-identical to pre-phase).
2.`render_pdf_pages(full_path, pages=candidates)` — a zero-byte placeholder page (unreadable) is skipped with a warning (the phase-133 handling).
3. each candidate in order: `describe_pdf_page(...)` fail-soft (`None` → skip that page, a warning log, continue — one dead page never kills the pass).
4. content assembly: every page 1..N as its `PAGE_SEPARATOR` + the page text + (when the page has a description: `\n\n[Page {n} illustration: {description}]`). The assembly with NO descriptions is byte-identical to `extract_pdf_text`'s output (a unit pin).
5. the digest over the FINAL content (the phase-126/133 convention — an unchanged file + a stable vision reply re-syncs as `unchanged`; a non-deterministic re-describe is a cost, not a bug — the phase-133 docstring documents it, A21 endorses it).
6. counters (stats log-only): `ImportSummary.pdfs_pictorial: int = 0` (a PDF with ≥1 described page) + `pdf_pictorial_pages: int = 0` (pages described) — `log()` fields only; the sync `detail` API shape is UNTOUCHED; ONE loud log line (PLAN §9): `pdf: pictorial pass source=<s> path=<p> — N candidate pages, M described (min_chars=<threshold>)`.
- **The viewer:** the description renders as plain text in the document viewer (A4 — PDFs are served as extracted text; the `[Page N illustration: …]` line IS content). No viewer change.
## Tasks
1.`01_settings_and_gate.md` — `BOR_PDF_PICTORIAL` + `BOR_PDF_PICTORIAL_MIN_CHARS` + `pictorial_pages` + unit tests.
2.`02_pictorial_vision_seam.md` — `PICTORIAL_PAGE_MODE` + `describe_pdf_page` + the `render_pdf_pages` page selector + the mock branch + unit tests.
3.`03_importer_pictorial_pass.md` — the `_index_pdf_file` pass + counters/log + the real IKEA-fixture integration repro + the unit/integration matrix.
4.`04_e2e_pictorial_pdf.md` — the dedicated Playwright suite, run in isolation, mock-only.
## Testing & Quality
- Unit: task 01 — `pictorial_pages` (threshold boundaries `<`/`=`/`>`, a whitespace-only page → a candidate, an empty dict → `[]`, ascending order); task 02 — `describe_pdf_page`'s message shape (a capturing fake LLM: `PICTORIAL_PROMPT` first, the page block after, the data URL; `None` on an empty reply / client error; the `DESCRIBE_PROMPT` byte-identity), the `render_pdf_pages` selector (subset order + count, `None` = all); task 03 — the assembly byte-identity + the branch matrix.
- Integration: task 03 — the real IKEA tar through the REAL importer (the canned vision fake, capturing style): every candidate page (computed IN the test from `extract_pdf_page_texts` + `pictorial_pages` — no hardcoded page list) carries a `[Page N illustration: …]` block, the text-heavy pages (2–5) do NOT, the chunks carry the description, the title is "Assembly Instructions"; a re-sync with the same fake → `unchanged` (digest stable, zero added vision calls); a `BOR_PDF_PICTORIAL=0` module app → the content is byte-identical to `extract_pdf_text`.
- E2E: `tests/e2e/test_pictorial_pdf.py` (new, task 04) — in isolation, mock-only.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] the owner's `ikea_instructions.tar` imports with described illustration blocks on every sparse page (the integration repro green — the owner's exact file)
- [ ] a chat question about the assembly content is grounded on the described pages (the E2E)
- [ ] a clean-text PDF never burns a vision call (the zero-candidates no-op is pinned) and a garbage-text PDF still takes the phase-133 fallback unchanged (the precedence pin)
- [ ]`BOR_PDF_PICTORIAL=0` → the content is byte-identical to pre-phase
- [ ]`uv run pytest --cov=app --cov-report=term-missing` green, TOTAL >90%; `uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` green in isolation; the pre-existing `test_pdf_support.py` / `test_garbage_pdf.py` / `test_pdf_image_context.py` suites green unedited
- [ ]`uv run ruff check . && uv run pyright` clean
- [ ] one atomic Conventional Commits commit, `--no-gpg-sign`; phase dir moved to `.agents/phases/complete/`
## Locked decisions
- **A1 — sparse-page detection is the deterministic per-page character threshold (default 100, `BOR_PDF_PICTORIAL_MIN_CHARS`) — no LLM call in the gate (owner-confirmed 2026-09-30, roadmap confirmation).**
- **A2 — NO LLM judge: every described sparse page's description appends to the content (owner-selected 2026-09-30).**
- **A3 — no page cap, no call budget (PLAN A21); `BOR_PDF_PICTORIAL` kill-switch, default ON (owner-confirmed 2026-09-30).**
- **A4 — the description appends to the page block as `[Page N illustration: …]`; the digest is over the final content (the phase-126/133 convention) (owner-confirmed 2026-09-30).**
## Commit
```bash
git add app/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(import): pictorial PDF support — sparse pages rendered + vision-described into the indexed content"
# Task 01 — The kill-switch, the threshold setting, and the sparse-page gate
**Phase:**`140_pictorial_pdf_pages` · **Source:**`TODO.md:5` — "…We need a way to determine when that's neccesary. …More information is better when generating the text embeddings since that increases the chance the user's search will hit something."
**Story:** n/a
## Objective
Two settings (the feature toggle + the sparseness threshold) and the pure, deterministic function that selects a PDF's pictorial pages — the TODO's "a way to determine when that's necessary", with no LLM call.
## Work
1.`app/config.py` — `pdf_pictorial: bool = True` (env `BOR_PDF_PICTORIAL` — the house kill-switch: `0`/`false` disables the pictorial pass entirely; the `images` toggle's naming/docstring pattern at L379) + `pdf_pictorial_min_chars: int = 100` (env `BOR_PDF_PICTORIAL_MIN_CHARS`; a `field_validator`: ≥ 0 — 0 = every page is a candidate (PLAN A21), negative raises with the named field, the house fail-loud pattern). Document both in the `Settings` docstring convention and in `.env.example` (the comment-block pattern of the neighboring settings).
2.`app/rag/pdf.py` — `def pictorial_pages(page_texts: dict[int, str], *, min_chars: int) -> list[int]`: the 1-based page numbers whose `len(text.strip()) < min_chars`, ascending — pure; the docstring carries the contract + the IKEA example (`{"1": "BESTÅ", "2": <7 KB of warnings>, "6": "Illustration 6"}` at `min_chars=100` → `[1, 6, …]`).
- ASSUMPTION: the threshold is STRICTLY less-than — a page with exactly `min_chars` stripped chars is NOT sparse (the boundary is pinned in the tests).
## Testing & Quality
- Unit: `tests/unit/test_pictorial_gate.py` (new) — the validators (0 ok, 100 ok, −1 raises with the named field, the defaults — toggle on, 100) and `pictorial_pages` (the `<`/`=`/`>` boundary, a whitespace-only page → a candidate, an empty dict → `[]`, ascending order, a large dict).
- Coverage: **>90%** on the new/modified code.
## Completion Criteria
- [ ]`uv run pytest tests/unit/test_pictorial_gate.py -v` green
- [ ]`uv run pytest` green; `uv run ruff check . && uv run pyright` clean
# Task 02 — The `PICTORIAL_PAGE_MODE` vision seam + the render selector
**Phase:**`140_pictorial_pdf_pages` · **Source:**`TODO.md:5` — "…Take a screenshot of the first page or pages 8 onward… My suggestion is to screenshot the pdf, generate a text summary… use the screenshot or rendered page rather in addition to the extracted text."
**Story:** n/a
## Objective
A dedicated, deterministic, fail-soft vision call for one rendered PDF page (the page's own text as context, so the model describes what the text does NOT) — plus the ability to render only the candidate pages of a large PDF.
## Work
1.`app/rag/summarizer.py` — `PICTORIAL_PAGE_MODE = "PICTORIAL_PAGE_MODE"` + `PICTORIAL_INSTRUCTION` (the locked wording from the phase overview's design) + `PICTORIAL_PROMPT = f"{PICTORIAL_PAGE_MODE}: {PICTORIAL_INSTRUCTION}"` — the marker convention next to `IMAGE_DESCRIPTION_MODE` (L75-95).
2.`app/rag/summarizer.py` — `async def describe_pdf_page(llm, *, data: bytes, mime: str, page: int, page_text: str | None, settings: Settings | None = None) -> str | None` — the same one-chat-call multimodal contract as `describe_image` (L208): the user message `[{type: "text", text: PICTORIAL_PROMPT + "\n\nPage {page} of the PDF. Text extracted from this page:\n" + (page_text.strip() or "(none)")}, {type: "image_url", image_url: {url: <data URL from data + mime>}}]` — no system prompt, no tools, no app-level retry; an empty reply → `None` (the house one-shot empty-content policy); a client failure → `None` (fail-soft — one dead page never stalls a sync).
3.`app/rag/pdf.py` — `render_pdf_pages(pdf_path, *, dpi: int = 150, pages: Sequence[int] | None = None)`: the optional 1-based selector — render only the named pages, ascending, one PNG each (an unreadable named page → the zero-byte placeholder at its position, as today); `None` = all pages — the phase-133 caller stays byte-identical (its existing tests pin it).
4.`tests/e2e/mock_llm.py` — ONE new deterministic branch (house style + a module-header docstring entry): a chat request whose text part contains `PICTORIAL_PAGE_MODE` → the fixed canned reply `PICTORIAL_PAGE_ANSWER` (a distinctive, retrieval-friendly string, e.g. "Pictorial page: the illustration shows two parts being joined with an arrow.") — additive; the `IMAGE_DESCRIPTION_MODE` branch and every existing flow are untouched.
## Testing & Quality
- Unit: `tests/unit/test_pictorial_seam.py` (new) — a capturing fake LLM: the message shape (the text part starts with `PICTORIAL_PROMPT`, then the page block; the `"(none)"` case; the image part's data URL from the bytes + mime), `None` on an empty reply and on a client exception, the `DESCRIBE_PROMPT` byte-identity (regression pin — the string is untouched); `render_pdf_pages`' selector (the subset's count + order + PNG magic bytes, `None` = all — a ≥2-page fixture PDF, the phase-126/133 recipe).
- Coverage: **>90%** on the new/modified code.
## Completion Criteria
- [ ]`uv run pytest tests/unit/test_pictorial_seam.py -v` green
- [ ]`uv run pytest` green (the pre-existing vision suites — `test_image_documents.py` et al. — pass unedited)
- [ ]`uv run ruff check . && uv run pyright` clean
# Task 03 — The `_index_pdf_file` pictorial pass + the IKEA repro
**Phase:**`140_pictorial_pdf_pages` · **Source:**`TODO.md:5` — "I've added a new \"ikea_instructions.tar\" to test_sources. This is an example pdf that stil isn't working… We need to support this kind of pdf, and in this case showing rendered pages of the pdf is more helpful than the extracted text… use the screenshot or rendered page rather in addition to the extracted text."
**Story:** n/a
## Objective
The import pass itself: after the phase-133 garbage gate, a usable-text PDF's sparse pages are rendered + vision-described + appended to the page content — and the owner's exact IKEA file is pinned as an integration regression.
## Work
1.`app/rag/importer.py` — `_index_pdf_file` (L1140): after the existing phase-133 gate block (the precedence is locked — an unusable layer takes `_fallback_page_screenshots` and the pass NEVER runs), when `settings.pdf_pictorial`: `page_texts = extract_pdf_page_texts(full_path)`; `candidates = pictorial_pages(page_texts, min_chars=settings.pdf_pictorial_min_chars)`; zero candidates → skip entirely (no render, no vision — a clean-text PDF is byte-identical to pre-phase); otherwise render (`render_pdf_pages(full_path, pages=candidates)`, the zero-byte placeholder page skipped with a warning), describe each candidate (`describe_pdf_page`, fail-soft: `None` → a warning + skip the page), and assemble the content: every page = its `PAGE_SEPARATOR` + the page text + `\n\n[Page {n} illustration: {description}]` for the described pages — the assembly with NO descriptions is byte-identical to `extract_pdf_text`'s output (a pin).
2.`app/rag/importer.py` — the digest over the FINAL content is the existing code (the pass runs before it, exactly as the phase-133 fallback does); the unchanged early-return, the chunk + embed, and the `lite` summary pipeline are untouched downstream.
3.`app/rag/importer.py` — `ImportSummary` (L212): `pdfs_pictorial: int = 0` + `pdf_pictorial_pages: int = 0` — `log()` fields only (the stats log-only convention; the sync `detail` API shape is untouched) + ONE loud log line (PLAN §9): `pdf: pictorial pass source=<s> path=<p> — N candidate pages, M described (min_chars=<threshold>)`.
4.`tests/integration/test_importer.py` (extend) — the IKEA repro (the phase-133 real-fixture convention: `test_sources/` is gitignored — extract the tar to `tmp_path`, never the repo; if the tar is absent the test fails loudly with a named message, the owner's machine always has it): run the REAL `import_sources` over the extracted PDF with a fake multimodal LLM (the canned per-page reply; a capturing fake that records every `PICTORIAL_PAGE_MODE` call):
- the candidates are computed IN the test from `extract_pdf_page_texts` + `pictorial_pages` (no hardcoded page list — the fixture is stable but the test must not rot);
- every candidate page's content block carries `[Page N illustration: <canned>]`; the text-heavy pages (2–5) carry NO block;
- the document's chunks carry the canned description (assert the string in the joined chunk text);
- the title is "Assembly Instructions" (the PDF metadata);
- a second `import_sources` run with the same fake → the file counts `unchanged` (digest stable, ZERO added vision calls);
- a `BOR_PDF_PICTORIAL=0` module app → the stored content == `extract_pdf_text`'s output (byte-identical);
- a garbage-text PDF (the phase-133 synthetic recipe) with `pdf_pictorial` ON → the phase-133 fallback fires (the content is the full-page descriptions), the pictorial pass does NOT run (the fake records zero `PICTORIAL_PAGE_MODE` calls) — the precedence pin.
## Testing & Quality
- Unit: `tests/unit/test_pictorial_import.py` (new) — the assembly byte-identity (no descriptions) + the branch matrix (feature off, zero candidates, a fail-soft `None` page, the unreadable-page placeholder skip) with a capturing fake LLM + the synthetic-PDF recipe (the `test_pdf_support.py` pattern).
- Integration: the step-4 matrix above.
- Coverage: **>90%** on the new/modified code.
## Completion Criteria
- [ ] the IKEA tar imports with a description block on every sparse page + `unchanged` on the re-sync (the repro green)
- [ ]`uv run pytest tests/unit/test_pictorial_import.py -v` green
- [ ]`uv run pytest` green, coverage >90%; `uv run ruff check . && uv run pyright` clean
# Task 04 — The dedicated E2E (synthetic pictorial PDF, mock-only, in isolation)
**Phase:**`140_pictorial_pdf_pages` · **Source:**`TODO.md:5` — "…in this case showing rendered pages of the pdf is more helpful than the extracted text… More information is better when generating the text embeddings since that increases the chance the user's search will hit something."
**Story:** n/a
## Objective
One dedicated Playwright file, run in isolation (AGENTS.md rules 4/9), proves the owner's scenario end to end: upload a pictorial PDF → sync → the indexed content carries the described page → a chat question grounded on the illustration is answered.
## Work
1.`tests/e2e/test_pictorial_pdf.py` (new) — the `test_pdf_support.py` structure (synthetic-PDF module app, the admin upload flow): build a 2-page PDF with PyMuPDF (the recipe): page 1 = a 500+ char text paragraph; page 2 = an `Illustration 2` line + a drawn vector figure (a rectangle + an arrow — the sparse page); tar it → `POST /api/git-sources/upload` (202) → the Sync button → the document appears on the Sources page:
- (a) the document viewer's content shows the page-1 text WITHOUT an illustration block and page 2 WITH `[Page 2 illustration: <the mock's canned PICITORIAL_PAGE_ANSWER>]` (the task-02 `PICTORIAL_PAGE_MODE` branch — deterministic);
- (b) a chat question that ONLY the illustration answers (the canned reply names a distinctive phrase — the question: "How are the two parts joined on page two?") → the SSE completes without deflecting (the described page's embedding retrieves — the mock's deterministic token-overlap embeddings make the phrase → description hit) → the `done` event's `sources` includes the PDF and the answer echoes the phrase;
- (c) the sync response's `detail` JSON keeps its pre-phase keys (no new field — the stats log-only convention) — a cheap key-set assertion.
2. No other file changes (the mock branch shipped in task 02).
## Testing & Quality
- E2E: this file, in isolation: `uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` (DB up: `podman compose up -d db`).
- Coverage: **>90%** on `app/` (the phase gate runs the full suite in the final pass).
## Completion Criteria
- [ ]`uv run pytest tests/e2e/test_pictorial_pdf.py -v --no-cov` green in isolation
- [ ] the pre-existing `test_pdf_support.py` / `test_garbage_pdf.py` / `test_pdf_image_context.py` suites green unedited
- [ ] full gate: `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean
# Phase 141 — `read` dedupe tells the truth: only fully-covered re-reads are refused
**Source:**`TODO.md` L3 — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`app/rag/agent.py` — `AgentHolder` (L1379+: `read_docs` / `read_truncations` / `shown_images` bookkeeping), `_execute_tool`'s `read` branch (L1512+: `_read_range_args` validation ~L1523, the dedupe `known` set ~L1539, the `truncated_shown` map ~L1548, the raw-arg fast path ~L1557, the range-read dedupe check ~L1577, the `read_docs` append ~L1600, the result shapes ~L1630+), the refusal texts `ALREADY_IN_CONTEXT` (L519), `REREAD_TRUNCATED` (L600), `OFFSET_PAST_END` (L616); `tests/unit/test_agent.py` (the read-branch unit suite — the phase-131 dedupe-matrix pins); `tests/e2e/mock_llm.py` (`_READ_RESULT_PREFIX` L1412 — the first-line header contract the mock parses), `tests/e2e/test_read_line_ranges.py` (the phase-131 flow). Root cause: `known` (the `read_docs` pair set) records ANY successful read — including a RANGE read — as if the FULL document were in context; the phase-131 matrix's "range read + doc in `known` + not truncated → `ALREADY_IN_CONTEXT`" row is false whenever the earlier read was a range (only the slice reached the model), so every attempt to read a DIFFERENT range is refused with a claim that the full text is in the prompt.
**Locked (owner-confirmed 2026-09-30, chat):** coverage-tracking dedupe — a range read proceeds unless the requested lines are FULLY covered by earlier reads; a plain re-read of a partially-covered document gets the "you have lines a–b — read the rest with `offset`/`limit`" teaching instead of the false `ALREADY_IN_CONTEXT`.
## Objective
The agent stops dead-ending on multi-range documents: the holder tracks exactly WHICH line ranges of each document are in the model's context, the dedupe matrix refuses only genuinely-covered re-reads (now with a truthful claim), and the owner's stuck scenario — grep → read range A → read range B of the same document — completes with both ranges in context.
## Dependencies
-`140_pictorial_pdf_pages` (complete) — pipeline order only; code dependencies are the completed agent surface (phases 37/70/72/95/118/131/139).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Coverage state (task 01):** `AgentHolder` gains `read_coverage: list[tuple[str, str, int, int]]` — one `(source, path, first_line, last_line)` record per covered span, in read order, keyed by the RESOLVED pair (the phase-131 `truncated_shown` keying lesson: the raw-arg split and the resolved pair are the same pair by the `_resolve_path` first-slash contract, L1229+). Coverage recording:
- a plain read at-or-under-cap covers `(1, total_lines)`;
- a truncated plain read (char cap) covers `(1, the number of lines in content[:cap])` (counted from the actual cut, not derived from the char count);
- a range read covers `(first, end)` — the TRUE extent the `lines: {first}-{end} of {total}` header already reports (an overshooting `limit` is clamped); a truncated range read covers `(first, the last line inside slice[:cap])` the same way.
`read_docs` / `read_truncations` keep their EXACT current meaning (API-layer signal, SSE matching, per-turn log) — the coverage list is NEW state beside them, not a replacement.
- **Dedupe matrix (task 02)** — `covered(a, b)` = the requested span is contained in the union of that document's coverage records (merge overlapping/adjacent spans first; containment is per-line, inclusive):
| call | coverage of the doc | result |
|---|---|---|
| plain `read(path)` | empty | full read (unchanged) |
| plain `read(path)` | partial | NEW/retargeted teaching: the covered span(s) named, "read the rest with `offset`/`limit`" (the `REREAD_TRUNCATED` role — generalized: `REREAD_TRUNCATED` is RETIRED as the partial-plain-read line, its char-based copy replaced by the line-based one) |
| `read(path, offset, limit)` | requested span fully covered | `ALREADY_IN_CONTEXT` — now TRUE (the named lines ARE in the prompt) |
| `read(path, offset, limit)` | not fully covered | range read PROCEEDS (the owner's stuck-scenario fix — includes the phase-131 truncated-then-range row, which the coverage state now covers: a truncated first read covers only its shown lines) |
The `BAD_RANGE_ARGS` and `OFFSET_PAST_END` refusals keep their exact firing order (before dedupe / after resolve respectively). The raw-arg fast path (no DB lookup) survives for the rows answerable from holder state alone (full-coverage plain re-read, fully-covered range — the `total_lines` for the latter's `OFFSET_PAST_END` still needs the resolve).
- **Prompt consistency (task 02, step 4):** `TOOLS_SECTION`'s re-read sentence ("Do not re-read a document you have already read — its full text is already in your prompt") is re-cut to the coverage-true form (read-IN-FULL docs: don't re-read; PARTIAL docs — range/truncated reads: read the other parts with `offset`/`limit`). Without this the model is still taught the pre-phase rule and would not bother trying the second range that task 02 now allows.
- **Repro (task 03):** unit matrix (every row above, incl. truncated variants and span-merge edges) + an integration test of the owner's scenario verbatim in spirit: one document, grep → read range A → read range B → both ranges' content reach the model, then a third read of range A is refused with `ALREADY_IN_CONTEXT`.
- **E2E (task 04):** `tests/e2e/mock_llm.py` gains ONE scripted flow (house style — user-message marker, module-header docstring entry): the marker `read two ranges` → request 1 a `read(path, offset, limit)` call, request 2 a SECOND `read` with a DIFFERENT offset on the same doc, request 3 the answer echoing a marker line from the second range (proving the second slice reached the model). `tests/e2e/test_read_range_dedupe.py` (dedicated, in isolation, mock-only): a KB fixture with ONE large doc carrying two distinct marker lines far apart; ask the marker question → assert both `read` tool frames succeeded (no refusal in the result surface) and the final answer echoes the second marker line.
## Tasks
1.`01_coverage_tracking.md` — `AgentHolder.read_coverage` + the coverage recording in the read branch.
2.`02_dedupe_matrix_rework.md` — the new matrix, the generalized partial-read teaching, `REREAD_TRUNCATED` retirement.
3.`03_two_range_repro.md` — the full unit matrix + the owner-scenario integration repro.
4.`04_e2e_mock_flow.md` — the mock `read two ranges` flow + the dedicated E2E file.
## Testing & Quality
- Unit: the full 6-row matrix with truncated variants; span-merge edges (adjacent, overlapping, contained, disjoint spans); coverage recording for all four read outcomes; the refusal-text pins; the fast-path no-DB rows.
- Integration: the two-range repro (task 03) — the owner's stuck scenario end-to-end through `run_agent`'s loop.
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] two DIFFERENT ranges of the same document both reach the model (the owner's bug is gone); a re-read of fully-covered lines is still refused — with a claim that is now true
- [ ] a plain re-read of a partially-covered document gets the "read the rest with `offset`/`limit`" teaching (the false full-text claim is gone in BOTH refusal directions)
- [ ] plain-read results at-or-under-cap and the phase-131 result shapes are byte-identical (the existing pins pass unchanged)
- [ ]`uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the grep/ls surfaces, the truncation notice, the `read_docs`/`read_truncations` API signals)
# Task 01 — coverage tracking: which lines of each document are actually in the model's context
**Phase:**`141_read_range_dedupe_fix` · **Source:**`TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in your context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
`AgentHolder` records, per document, exactly which line spans a successful `read` put in the model's context — the state the reworked dedupe matrix (task 02) decides on. No dedupe behavior changes in THIS task (task 02 does that); this task only adds the recording, so the suite stays green throughout.
## Work
1.`app/rag/agent.py` — `AgentHolder` (L1379+): add `read_coverage: list[tuple[str, str, int, int]]` = one `(source, path, first_line, last_line)` record per covered span, in read order, keyed by the RESOLVED pair (the `_resolve_path` first-slash contract, L1229+ — the same pair `read_docs` keys on; the phase-131 `truncated_shown` keying comment ~L1546 is the precedent for why the raw-arg split and the resolved pair agree). Document the field on the dataclass docstring (the `read_truncations` doc-block style, L1386+): spans are inclusive line numbers over `content.splitlines()`; one span per SUCCESSFUL read (a refused read records nothing — the refusal style of every refusal: counts in nothing).
2.`app/rag/agent.py` — the read branch's four successful outcomes record their span (the `total_lines`/`end`/`first` locals already exist at the record sites, ~L1610+):
- truncated plain read (~L1636): `(1, len(doc.content[:cap].splitlines()))` — counted from the ACTUAL cut string (the last visible line is the one ending at or before `cap`; `splitlines()` on the cut is the truth — do not derive a line count from the char count);
- range read at-or-under-cap (~L1686): `(first, end)` — the TRUE extent the `lines: {first}-{end} of {total}` header reports (an overshooting `limit` is already clamped at ~L1678);
- truncated range read (~L1668): `(first, the last line fully inside slice[:cap])` — `len(slice_text[:cap].splitlines())` added to `first - 1`, clamped to `end` (a cut mid-line means that line is NOT fully in context — the span ends at the last COMPLETE line).
3.`tests/unit/test_agent.py` — unit tests: each of the four outcomes records exactly its span (assert the holder list after the call); a refused call (bad range args, unknown doc, offset past end, already-covered) records NOTHING; spans are keyed by the resolved pair (a doc read through its canonical `source/path` argument); existing `read_docs` / `read_truncations` assertions pass UNCHANGED (those signals are byte-identical).
- ASSUMPTION: the covered-line count for a truncated read is computed from the cut string's `splitlines()` (inclusive, whole lines only) — a trailing cut line that ends exactly at `cap` counts as covered; the char cap is never re-derived into lines arithmetically. (The TODO item left the counting rule open; whole-line containment is what makes "fully covered" checkable per line.)
## Testing & Quality
- Unit: the four record sites + the five no-record refusals + resolved-pair keying (above); every pre-existing read-branch test passes unchanged.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] every successful read shape appends exactly one coverage span with the lines documented above; refusals append nothing
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the holder's existing fields, the result strings, the SSE frames)
# Task 02 — the dedupe matrix rework: refuse only what is truly already in context
**Phase:**`141_read_range_dedupe_fix` · **Source:**`TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The read branch decides dedupe from the coverage state (task 01) instead of the pair set alone: a range read proceeds unless its lines are fully covered, a plain re-read of a partially-covered document is taught the "read the rest" path, and `ALREADY_IN_CONTEXT` fires only when its claim is true.
## Work
1.`app/rag/agent.py` — a small coverage helper beside `_read_range_args` (L1490+): `covered_spans(records, source, path)` → the merged span list for one doc (merge overlapping/ADJACENT spans — `(1,10)+(11,20)` → `(1,20)` — so adjacency counts as continuous coverage), and `fully_covered(spans, first, last)` → bool (the requested span inside the merged union, per-line, inclusive). Pure functions over holder state — no DB.
2.`app/rag/agent.py` — rework the read branch's dedupe (L1539+), keeping the firing ORDER of the other refusals:
- the `BAD_RANGE_ARGS` check stays FIRST (before any dedupe — a bad range arg is refused regardless of context, phase-131 contract);
- the raw-arg fast path is RETIRED: whether a span is "full" needs the document's `total_lines`, which only the resolve knows — so EVERY dedupe row resolves the doc first (one cheap indexed lookup; the pre-phase fast path's only saving was that one query, and the owner's bug — a false refusal costs a whole stuck turn — is what forces the trade). After the resolve: `total_lines = len(doc.content.splitlines())`, then the matrix below runs against `(doc.source, doc.path, total_lines)` and the coverage state. A fully-covered PLAIN re-read — the only row the old fast path handled — still refuses with the byte-identical `ALREADY_IN_CONTEXT`; its implementation just goes through the resolve.
- matrix (the phase-131 table in 00_phase.md is authoritative):
| call | coverage | result |
|---|---|---|
| plain | empty | full read (the existing at-or-under-cap / truncated paths, untouched bytes) |
| plain | full doc (a merged span == `(1, total_lines)`) | `ALREADY_IN_CONTEXT` (byte-identical line, L519) |
| plain | partial | the NEW teaching line (below) |
| range | not fully covered | range read PROCEEDS (the owner's fix) |
- the `OFFSET_PAST_END` refusal (L616, firing after the resolve, before the matrix's range rows) stays where it is — an offset past the end is refused regardless of coverage.
3.`app/rag/agent.py` — the partial-plain-read teaching: RETIRE `REREAD_TRUNCATED` (L600 — its char-based `{shown}` copy is replaced) and add `REREAD_PARTIAL`: one line, same refusal contract (counts in nothing, consumes a round, no re-emit), the covered span(s) named in line form and the working workflow pointed at: "You have lines {covered} of {source}/{path} in your context ({total} lines total). To read the rest, find where your text is with grep (it reports line numbers), then call read with `offset` (1-based first line) and `limit` (number of lines)." — `{covered}` renders the merged spans as comma-joined `a–b` pairs (one span → the bare `a–b`; a single line → `a`). The phase-131 truncated-then-plain-read scenario lands on THIS line now (a truncated first read covers a partial span) — same teaching role, line-based truth.
4.`app/rag/prompts.py` — `TOOLS_SECTION`'s re-read sentence stays consistent with the new semantics: "Do not re-read a document you have already read — its full text is already in your prompt; answer directly from it." becomes the coverage-true form: do not re-read a document you have already read IN FULL — its full text is in the prompt, answer from it — but if you have read only PART of a document (a range read, or a truncated read), read the other parts with `offset`/`limit` — the document is not complete in your context until you have read it all. The sentence stays in its current position (the `read` clause); the mock keys off the `<tools>` marker's PRESENCE, not the wording (the phase-37 contract), so the mock is untouched. The HIGH prompt's byte-identity pins that assert the OLD sentence are re-pinned to the new one (the section's other bytes are unchanged).
5.`tests/unit/test_agent.py` — re-pin the dedupe matrix (the phase-131 pins that asserted the OLD matrix's "range + known + not truncated → ALREADY_IN_CONTEXT" row are the ones that change — every other pin, incl. byte-identical result strings for plain reads, `BAD_RANGE_ARGS`, `OFFSET_PAST_END`, `MISSING_READ_ARGS`, the no-document refusals, passes unchanged):
- the 6 matrix rows × {truncated, non-truncated first read} where defined;
- the partial-plain-read line: span rendering (one span, multiple merged spans, a single line), `{total}` from the real doc;
- span-merge edges: adjacent spans merge (coverage contiguous), overlapping spans merge, disjoint spans stay separate (a gap → the requested span spanning the gap is NOT fully covered → proceeds);
- the fast-path retirement: a plain re-read of a fully-covered doc still refuses with the byte-identical `ALREADY_IN_CONTEXT` (the row's observable contract is unchanged; its implementation now resolves first).
## Testing & Quality
- Unit: the full matrix + edges above; the refusal-text pins (`ALREADY_IN_CONTEXT` byte-identical; `REREAD_PARTIAL` exact copy); the pre-existing read-branch suite minus the re-pinned rows.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] a range read of a document earlier read ONLY as a different range proceeds (the owner's bug); a range read of fully-covered lines is refused with `ALREADY_IN_CONTEXT`
- [ ] a plain re-read of a partially-covered document gets `REREAD_PARTIAL` with the true covered span(s); `REREAD_TRUNCATED` no longer exists
- [ ] every byte-identical pin that is NOT re-pinned in task 02's matrix changes passes unchanged; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work outside the documented matrix rows
# Task 03 — the owner's stuck scenario, proven: two ranges of one document both reach the model
**Phase:**`141_read_range_dedupe_fix` · **Source:**`TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The bug the owner hit is locked as a test: an agent turn that greps, reads range A of a large document, then reads range B — both ranges' content reach the model and the answer can cite both — and a third read of range A is refused with the (now true) `ALREADY_IN_CONTEXT`.
## Work
1.`tests/integration/` (the read-branch integration home — the phase-131 handbook repro lives in the unit/integration suite per `.agents/phases/complete/131_read_line_ranges/03_handbook_repro.md`; follow that file's fixture conventions) — the two-range repro:
- fixture: ONE text document, ≥ 200 lines, with two distinct marker lines far apart (marker A near line ~50, marker B near line ~150) — the document's content is GENERATED in the test (no binary fixture; the handbook repro is the separate phase-131 asset, untouched);
- settings: `read_max_chars` high enough that the FULL document is under the cap is NOT the point — run BOTH variants: (a) doc under the cap (the pure dedupe case — the pre-phase code refused range B with `ALREADY_IN_CONTEXT`), (b) doc over the cap with a truncated first plain read (the phase-131 row, now via coverage: the range B read proceeds, the plain re-read gets `REREAD_PARTIAL`);
- scripted fake LLM (the `tests/unit/test_agent.py` round-shape pattern): round 1 → `read(path, offset=1, limit=40)` (range A); round 2 → `read(path, offset=<marker B's line − 10>, limit=30)` (range B); round 3 → `read(path, offset=1, limit=40)` again (the exact re-covered range A); round 4 → the answer echoing BOTH marker lines (the read-echo convention — the model can only echo what reached it).
- asserts: round 2's result is the range-B slice (its `lines:` header + the marker-B line present — NOT a refusal); round 3's result is `ALREADY_IN_CONTEXT`; round 4's echoed answer contains BOTH markers (the bug: pre-phase, the echo could contain only marker A).
2.`tests/unit/test_agent.py` — the matrix unit coverage from task 02 that is scenario-shaped rather than row-shaped: the adjacent-span edge as a scenario (two reads that together cover 1–100 of a 200-line doc → a plain re-read gets `REREAD_PARTIAL` naming `1–100`, and `read(offset=101)` proceeds).
## Testing & Quality
- Integration: the two-range repro (both variants) — the owner's scenario end-to-end through the agent loop.
- Unit: the scenario-shaped matrix edges above.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] the two-range repro passes: range B's slice reaches the model after range A; the exact re-read of range A is refused; the final answer echoes both markers
- [ ] the truncated-variant passes: after a truncated first plain read, the range read proceeds and the plain re-read gets `REREAD_PARTIAL`
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the repro is additive; no existing test file is modified)
# Task 04 — the mock `read two ranges` flow + the dedicated E2E
**Phase:**`141_read_range_dedupe_fix` · **Source:**`TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The browser-visible proof: the deterministic mock LLM drives a two-range turn, and the dedicated Playwright file asserts that both `read` tool frames succeed and the final answer carries content from the SECOND range — the owner's stuck scenario, end-to-end through the SSE stream.
## Work
1.`tests/e2e/mock_llm.py` — ONE scripted flow, house style (a user-message marker; a module-header docstring entry like the phase-131 `read the lines` flow):
- marker: the user message contains `read two ranges`;
- request 2 (a `tool`-role read result whose FIRST line starts with `_READ_RESULT_PREFIX` (L1412) and which carries a `lines: 1-100 of …` header): a SECOND `read` tool call on the SAME doc with `{offset: 101, limit: 100}` — the pre-phase server would have answered the first read fine and refused the second; the flow only works if the second proceeds;
- request 3 (the second read result present — assert its `lines: 101-…` header to keep the mock's own keying honest): the answer "Both parts read." + the first 80 chars of the SECOND read result (the existing read-echo shape — the marker line lives in range B, so an echo of it proves the slice reached the model).
2.`tests/e2e/test_read_range_dedupe.py` — dedicated Playwright file, run in isolation, mock-only (the house fixture conventions of `tests/e2e/test_read_line_ranges.py` — truncate/re-import per module, `?v=` asset pins, the SSE assertion helpers):
- the KB fixture: ONE large text document (> 100 lines; range A lines 1–100 carry marker line `TWO_RANGES_A_LINE` near line ~50, range B lines 101+ carry marker line `TWO_RANGES_B_LINE` near line ~150);
- test 1: ask the `read two ranges` question → collect the SSE frames → assert exactly the two `tool` frames (both `read`, the second carrying `offset: 101`), NO refusal surface in either tool result (the results carry the `lines:` headers, not `Already in your context`), and the final `done` answer contains `TWO_RANGES_B_LINE`'s text;
- test 2 (the still-refused row, browser-visible): a second scripted marker flow is NOT added — instead reuse the phase-131 `read the lines` flow's existing E2E expectations (they still pass: the single-range behavior is unchanged) and add a focused integration-level assertion in `tests/unit/test_agent.py` if the mock surface can't express a refusal cleanly — the E2E's job is the HAPPY two-range path; the refusal row is unit-pinned in tasks 02/03.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: `tests/e2e/test_read_line_ranges.py` + `tests/e2e/test_read_range_tool_lines.py` + `tests/e2e/test_read_truncation_cap.py` still pass in isolation (the phase-95/131/139 contracts).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the mock flow's second range read succeeds through the real server (no refusal) and the answer echoes the second range's marker line
- [ ]`uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` green in isolation; the three pre-existing read E2E files still green in isolation
- [ ]`uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the mock's pre-existing flows are byte-identical; the new flow is additive)
# Phase 142 — the KB overview stays fresh: newest documents survive the cap, and the model is told the outline is partial
**Source:**`TODO.md` L4 — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… 'The <knowledge_base> section in the system prompt only generated summaries up to phase 113 at import time. The phases 128–137 exist as full markdown files… but their summaries weren't included in the initial KB index. This is a KB sync/generation gap, not a retrieval logic bug.'"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`app/rag/overview.py` — `regenerate_overview` (the document-list query `order_by(Document.source, Document.path)` ~L232, best-effort change-gated per the module docstring), `build_overview_prompt` (one line per doc `source — path — title — {first summary line}`, capped at `max_chars` with the shared `TRUNCATION_MARKER`), `load_kb_overview`; `app/config.py` L216 — `overview_input_max_chars` (`BOR_OVERVIEW_INPUT_MAX_CHARS`, default 40 000); `app/rag/prompts.py` — `_KB_INTRO` (L128–132: literally says "(generated at import time)") and `build_kb_section` (L399+); `app/api/sync.py` ~L590 — the overview regeneration gate (`summary.added + summary.updated > 0` — the sync IS change-gated correctly; the staleness is inside the generation, not the trigger); `tests/e2e/test_kb_overview.py` (the mock's `KB_OVERVIEW_MODE` digest = first 8 tokens of the document list — ORDER-DEPENDENT, must be re-pinned), `tests/integration/test_import_docs_overview.py` (the import-time trigger), `app/models.py` — `Document.created_at` (L123 — the DOCUMENT's date, phase 106: git-history/mtime-derived and owner-correctable, the same date the `read` result's `date:` line shows — the "when is this document from" signal, exactly what "the last N …" means). Root cause (traced): on the owner's 1 000-doc KB the document list is ≈100k+ chars; the 40k cap cuts a LEXICOGRAPHIC prefix (`order_by (source, path)` — "113_…" sorts before "128_…", and "131_…"–"140_" sort after "13_…"), so the newest phases never reach the lite model and the stored outline confidently describes a KB that ends mid-11x; the model then anchors "the last 10 phases" on that stale image.
**Locked (owner-confirmed 2026-09-30, chat):** recency ordering (newest documents first so they survive the cap; the cap itself is unchanged) + the honest `<knowledge_base>` intro (drops "(generated at import time)"; tells the model the outline is compact/possibly partial and to verify "latest/complete" questions with `ls`/`grep` first). No map-reduce rewrite this phase.
## Objective
"Summarize the last 10 phases" stops being a guess: the overview's input is ordered newest-first so recent documents are the ones the outline covers when the cap bites, and the `<knowledge_base>` intro stops teaching the model that a partial outline is the whole KB — it now says the outline is a possibly-partial picture and points at `ls`/`grep` for "what's the latest / the complete set" questions.
## Dependencies
-`141_read_range_dedupe_fix` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Ordering (task 02):** `regenerate_overview`'s query becomes `order_by(Document.created_at.desc(), Document.source, Document.path)` — newest documents FIRST (`created_at` is the document's date per phase 106 — the same date the `date:` line in a read result shows; the owner's phase-128–137 files carry newer git dates than the 113-era files, so they lead the list and survive the cap). The (source, path) secondary keys keep the tie-break deterministic for same-date docs (the common case within one commit/import — those fall back to the old lexicographic order, so same-date fixtures stay byte-identical). The cap (`BOR_OVERVIEW_INPUT_MAX_CHARS`, default 40 000) and the cap's cut+`TRUNCATION_MARKER` behavior are UNCHANGED (house truncation convention; the env knob stays the owner's lever). Zero other call-site changes: the sync's change-gate (task-31/32 trigger) already regenerates whenever the KB changes — the staleness was purely inside the list.
- **Intro (task 03):** `_KB_INTRO` rewritten (it is a prompt constant — PLAN §4's convention: persona/prompt text changes ship with the phase's prompt tests; the `<knowledge_base>` TAG and the section's cap behavior in `build_kb_section` stay byte-stable): the outline is a COMPACT, possibly partial picture of the KB that favors RECENT documents — and for questions about the latest state or the complete set ("the last N …", "everything about …"), verify with `ls` (drill down) / `grep` before answering from the outline. One short paragraph, same tone as `SUGGEST_INTRO` (L152).
- **Repro (task 01):** before any fix, a failing test proves the traced root cause: `build_overview_prompt` given a doc list whose joined lines exceed `max_chars` keeps the lexicographic FIRST lines and drops the last; and (integration) a KB where the newest docs' paths sort lexicographically LAST is regenerated with the pre-fix ordering → the stored outline (the mock's 8-token digest) names the OLD docs, not the new ones. The repro is written against the CURRENT code (red), then tasks 02/03 flip it green.
- **E2E (task 04):** dedicated file — a fixture KB whose document count × line length exceeds a FORCED small cap (`BOR_OVERVIEW_INPUT_MAX_CHARS` set low via the E2E settings fixture) → regenerate through the mock → the stored outline's digest tokens come from the NEWEST document (recency ordering visible end-to-end through the real prompt path), and the served chat prompt's `<knowledge_base>` section carries the NEW intro copy (asserted from the echo path or the direct prompt-build surface — the phase-31 E2E already exposes the section to the UI via the first-bullet echo).
## Tasks
1.`01_cap_ordering_repro.md` — the red repro tests (unit: cap keeps the lexicographic prefix; integration: newest-sorting-last docs missing from the regenerated outline).
2.`02_recency_ordering.md` — the `created_at.desc()` ordering + the order-dependent digest re-pins.
3.`03_honest_kb_intro.md` — the `_KB_INTRO` rewrite + the prompt tests.
4.`04_e2e_outline_freshness.md` — the dedicated E2E (forced cap → newest doc in the outline; the new intro served).
## Testing & Quality
- Unit: `build_overview_prompt` cap behavior unchanged (the cut + marker, the pathological budget); the new ordering (regeneration with mixed `created_at` docs → newest first; same-date tie-break by (source, path)); the intro copy pin.
- Integration: the import-time trigger still regenerates exactly on KB change (the pre-existing `tests/integration/test_import_docs_overview.py` green); the repro flipped green.
- E2E: one dedicated Playwright file, run in isolation, mock-only; `tests/e2e/test_kb_overview.py` (phase 31) green with re-pinned digest.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] with the cap forced, the regenerated outline is built from the NEWEST documents (the 1 000-doc "ends at phase 113" failure mode is gone: the newest sort-last docs are the ones represented)
- [ ] the served `<knowledge_base>` section carries the new honest intro (no "(generated at import time)"; the verify-with-`ls`/`grep` teaching present)
- [ ]`uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` green in isolation; `tests/e2e/test_kb_overview.py` still green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the cap env knob, the change-gated trigger, the `build_kb_section` cap/marker behavior, the deflect path)
# Task 01 — the red repro: the cap keeps a lexicographic prefix, so newest docs are the ones lost
**Phase:**`142_kb_overview_freshness` · **Source:**`TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… this is a KB sync/generation gap, not a retrieval logic bug."
**Story:** n/a
## Objective
Lock the traced root cause as failing tests BEFORE the fix: `build_overview_prompt` cuts the document list at the cap keeping the FIRST lines (which, under the current `order_by (source, path)`, are the lexicographically first — NOT the newest), and a regeneration on a KB whose newest documents sort lexicographically LAST produces an outline that names the old documents. These tests are red on the current code and turn green in tasks 02/03.
## Work
1.`tests/unit/test_overview.py` (the phase-31 unit home — if the overview units live elsewhere, e.g. inside `tests/unit/test_agent.py`'s neighbors, use the existing file for `build_overview_prompt`'s pins) — the cap-ordering repro, unit level, no LLM:
- build 20 fake doc rows whose `source — path` prefixes are `s — 01_old_a.md` … `s — 20_new_z.md` (a path ordering that ALSO puts the "newest" last — the owner's phase-dir shape: names like `113_…` vs `128_…` vs `131_…`), each line ~100 chars, `max_chars=500` → the prompt's user message holds ONLY the first lines (assert: `01_old_a` present, `20_new_z` ABSENT, the `TRUNCATION_MARKER` line present — the current behavior pinned as-is);
- the assertion this task ADDS is the one that will flip: a helper-level check that documents the INTENT — the list reaching the model should lead with the NEWEST rows — written as the expected post-fix order (fails pre-fix).
2.`tests/integration/test_import_docs_overview.py` (or its sibling for regeneration — the file that already drives `regenerate_overview` against the mock) — the KB-shape repro, integration level:
- a fixture KB shaped like the owner's: one source, folders/paths whose names sort lexicographically BEHIND their content's recency (e.g. an `old/…` batch + a `zz_new/…` batch whose paths sort LAST). The batches' `created_at` (the document date — phase 106 derives it from the file's mtime for a local fixture) must differ: set the files' mtimes (`os.utime`) before import so the old batch is genuinely older, OR update the old batch's `created_at` to a past date in a raw SQL step after import — the test owns its fixture, either seam is fine,
- regenerate through the mock `KB_OVERVIEW_MODE` (the 8-token digest of the document list, `tests/e2e/mock_llm.py` ~L2906) with a small `BOR_OVERVIEW_INPUT_MAX_CHARS` so the cap bites,
- assert the CURRENT failure: the stored outline's tokens come from the lexicographically-first (oldest) documents, NOT the newest — the exact "summaries only up to phase 113" shape, reproducible in a test.
- Mark both tests with the outcome they expect POST-fix (newest docs win); they FAIL now — that red is the repro. (Task 02 makes them green.)
## Testing & Quality
- Unit/integration: the two repro tests above — RED on the current code (asserted by running them in this task), green after task 02 (+ task 03 for the intro).
- Coverage: **>90%** on this task's new/modified code (tests — coverage applies to `app/` as a whole).
## Completion Criteria
- [ ] the two repro tests exist and FAIL on the pre-fix code for the documented reason (lexicographic-prefix cap; oldest docs in the outline) — the failure output names the root cause
- [ ] no application code changed in this task (the repro is tests-only)
- [ ] the rest of `uv run pytest` stays green (the repro tests are the only new failures)
# Task 02 — recency ordering: newest documents first in the overview's input
**Phase:**`142_kb_overview_freshness` · **Source:**`TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… this is a KB sync/generation gap, not a retrieval logic bug."
**Story:** n/a
## Objective
`regenerate_overview` builds the lite model's document list newest-first, so when `BOR_OVERVIEW_INPUT_MAX_CHARS` cuts the list, the documents that fall off are the OLDEST — and the outline (the model's a-priori KB picture) always represents the recent state of the KB. Task 01's red repro turns green.
## Work
1.`app/rag/overview.py` — `regenerate_overview`'s document-list query: `order_by(Document.created_at.desc(), Document.source, Document.path)`. Update the query's inline comment + the module docstring (the "one line per document" description): the ordering is RECENT-FIRST by the DOCUMENT's date (phase 106's `created_at` — git-history/mtime-derived, owner-correctable, `created_at_manual` rows included exactly as stored: the ordering reads the column, it never re-derives dates; the (source, path) tie-break keeps same-date docs in the old lexicographic order — deterministic for the mock's digest, and byte-identical for single-date fixtures). The cap, the cap's cut + `TRUNCATION_MARKER`, the zero-docs no-op, the `LLMError` fail-soft, the upsert — ALL unchanged.
2.`tests/unit/test_overview.py` — the unit pins:
- the task-01 cap test flips: with mixed `created_at` rows (the row shape `(source, path, title, summary)` is order-agnostic — the ORDER comes from the query, so the unit test for the QUERY ordering goes against the DB: a small session with 6 docs in two `created_at` batches → the SELECT returns newest batch first, (source, path) within a batch),
- the byte-identical pins for `build_overview_prompt` itself are UNTOUCHED (it still receives rows in whatever order the caller passes — its contract is order-preserving; only the CALLER's order changes).
3.`tests/integration/test_import_docs_overview.py` — the integration repro flips green: the second (newer) import batch's paths now lead the digest.
4. Re-pin the ORDER-DEPENDENT digest expectations that assumed the old order (the mock's 8 tokens are the first document line's tokens — under recency-first that is now the NEWEST doc's line):
-`tests/e2e/test_kb_overview.py` — `test_mock_generated_outline_is_stored_and_echoed`'s expected digest (compute it from the fixture's newest document, not the lexicographic first — the test's fixture documents and their `created_at` values decide; the test seeds its row directly for tests 1–3, so only test 4's regeneration digest is order-sensitive),
- any other pin asserting a specific first line of a regenerated overview (grep the suite for the digest literal — re-pin where the KB has more than one document with differing `created_at`; single-batch fixtures where every doc shares a `created_at` stay byte-identical by the (source, path) tie-break).
## Testing & Quality
- Unit/integration: the ordering tests + the flipped repro (task 01) + the re-pinned digests.
- Regression: the phase-31 `KB_OVERVIEW_MODE` branch in `tests/e2e/mock_llm.py` is UNCHANGED (it digests whatever list the app sends — the mock is order-agnostic by construction); `tests/e2e/test_kb_overview.py` green in isolation after the re-pins.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] task 01's two repro tests are GREEN (the newest documents lead the capped list; the outline represents the recent KB)
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_kb_overview.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases beyond the list ORDER (the cap env knob, the trigger gate, the fail-soft, the upsert, the mock's generation branch — all byte-identical)
# Task 03 — the honest `<knowledge_base>` intro: the outline is partial, and "latest" questions verify first
**Phase:**`142_kb_overview_freshness` · **Source:**`TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG…"
**Story:** n/a
## Objective
The model stops treating the outline as the KB's full, import-time-frozen inventory: `_KB_INTRO` says what the outline actually is (a compact, possibly partial picture that favors recent documents) and gives the one behavior that fixes the owner's question class — for "latest / complete set" questions, verify with the tools (`ls` drill-down / `grep`) before answering from the outline.
## Work
1.`app/rag/prompts.py` — rewrite `_KB_INTRO` (L128–132). Current: `"The basic categories of everything in this knowledge base (generated at import time):\n"`. New copy (short — the section is char-budgeted by `build_kb_section`; keep it 1–2 lines): the outline is a COMPACT picture of the knowledge base's categories and topics that FAVORS RECENT DOCUMENTS and may omit older or lesser ones — it is a map of what exists, not a complete inventory; for questions about the LATEST state or the COMPLETE set ("the last N …", "every …", "all the …"), verify with `ls` (drill into the folders) or `grep` before answering from the outline. The "(generated at import time)" phrase is GONE (it is false — the sync regenerates the row on KB change — and it taught the model to discount/freeze the section).
2.`app/rag/prompts.py` — leave `build_kb_section` (L399+) byte-stable: same tag, same cap loop, same pathological-budget behavior — ONLY the intro constant changes. Update the constant's doc comment (the phase-31 note) with this phase's rationale in one line.
3. Prompt tests — the house prompt-suite pins (`tests/unit/` — the file pinning `build_kb_section` / the HIGH prompt's section order, the phase-31 pins):
- the new intro copy pinned verbatim (the exact string),
- a no-overview prompt stays byte-identical to the pre-phase text (the phase-15 convention — the existing pin passes unchanged: an empty overview omits the whole section),
- the section's cap/marker behavior pins pass unchanged (the intro is inside the budget loop — a one-line-longer intro changes the pathological-budget edge only if a test pinned the exact cut against the OLD intro length; re-pin any such test to the new intro length — the BEHAVIOR is unchanged),
- the HIGH prompt's section-order pin (`<relevance>` → `<knowledge_base>` → `<tuning>` → body) passes unchanged.
4. The deflect path is UNTOUCHED (the LOW prompt carries no `<knowledge_base>` section — the existing byte-identity pins pass as-is).
## Testing & Quality
- Unit: the intro copy pin; the section-build pins (cap, marker, pathological budget, empty-omission); the section-order pin.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ]`_KB_INTRO` no longer contains "(generated at import time)"; it names the recency bias and the verify-with-`ls`/`grep` behavior (pinned verbatim)
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the section's tag/cap/marker, the deflect prompt's byte-identity, the section order)
# Task 04 — the dedicated E2E: the regenerated outline leads with the newest document, and the new intro is served
**Phase:**`142_kb_overview_freshness` · **Source:**`TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG…"
**Story:** n/a
## Objective
The browser-visible proof of the whole phase: with the cap forced to bite, a sync-path regeneration produces a stored outline whose digest comes from the NEWEST document (recency ordering, end-to-end through the real prompt path), and the served chat prompt's `<knowledge_base>` section carries the new honest intro.
## Work
1.`tests/e2e/test_kb_overview_freshness.py` — dedicated Playwright file, run in isolation, mock-only (the house conventions of `tests/e2e/test_kb_overview.py` — per-module truncate/re-import, the settings override fixture, the login helper, the SSE echo assertions):
- fixture: one source, TWO batches of documents whose document DATES differ (the old batch's files get past mtimes via `os.utime` before import, the new batch is current — phase 106's mtime fallback for local sources; the new batch's PATHS sort lexicographically LAST, e.g. `old/a.md`… vs `zz_new/z.md` — so the pre-fix lexicographic order and the post-fix recency order disagree about which document is first),
- settings: `BOR_OVERVIEW_INPUT_MAX_CHARS` set small enough (via the E2E settings override) that the document list is CUT — the cap must bite or the ordering is unobservable;
- test 1 (`test_regenerated_outline_leads_with_newest_doc`): import, then drive the regeneration through the mock the same way `test_kb_overview.py` test 4 does (the real `regenerate_overview` against the mock's `KB_OVERVIEW_MODE` digest) → the stored `kb_overview` row's 8 digest tokens come from the NEWEST batch's first (recency-ordered) line — assert the exact tokens (computed from the fixture in the test, not hardcoded wording);
- test 2 (`test_kb_section_carries_honest_intro`): the intro is a prompt constant — the deterministic surface is the section builder itself, called IN the E2E file the same way this file already calls `regenerate_overview`/`build_overview_prompt` directly against the app (the phase-31 file's own convention): `build_kb_section(<a seeded multi-line outline>)` → assert the section's lead line IS the new intro verbatim (no "generated at import time"), the tag + cap behavior intact; the UI injection path (the section reaching a rendered turn) is the phase-31 first-bullet echo, which a third lightweight test re-asserts here against the SEEDDED row so this file stands alone on the injection surface too;
- the pre-existing phase-31 file is NOT modified here (its re-pins landed in task 02).
2. Run the regression set in isolation (each file its own run — the suite-isolation contract): `test_kb_overview_freshness.py` (new), `test_kb_overview.py` (phase 31, task-02 re-pins).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Integration: the intro-observation fallback (if test 2 takes the `build_kb_section` surface) lives in the unit/integration suite, not the E2E.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] with the cap biting, the regenerated outline's digest tokens come from the NEWEST document (the owner's "ends at phase 113" shape is impossible — the newest docs lead)
- [ ] the new intro copy is pinned verbatim as the section's lead line (the `build_kb_section` surface), and the section still reaches a rendered turn (the phase-31 echo path)
- [ ]`uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` green in isolation; `tests/e2e/test_kb_overview.py` still green in isolation
- [ ]`uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the phase-31 injection path, the mock's generation branch)
# Phase 143 — the mobile keyboard stays down after an answer completes
**Source:**`TODO.md` L5 — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): `// done | error | stop → idle: always settle, always focus back.` … `input.focus({ preventScroll: true })` (L3492) — EVERY turn end (done, error, stop) re-focuses the composer; on a phone, focusing the textarea pops the virtual keyboard the moment the answer completes (the owner's annoyance). The existing `matchMedia` precedent in the file (L391: `reducedMotion` for `prefers-reduced-motion` — the module-level-constant shape); `tests/unit/test_frontend_scroll.py` — `test_turn_end_focus_does_not_scroll` (L213+: the source-level pin that the finally block contains the `preventScroll` focus line — must be EXTENDED, not broken); the composer is a ≥44px tap target on mobile (PLAN §7.1).
**Locked (owner-confirmed 2026-09-30, chat):** desktop-only refocus — the post-turn focus-back is skipped on coarse-pointer devices (`matchMedia("(pointer: coarse)")`), kept on desktop so the next question can be typed immediately.
## Objective
A phone user reads the completed answer with the keyboard DOWN: the composer is only re-focused at turn end on fine-pointer (desktop) devices; a coarse-pointer device stays unfocused (the next question starts with a tap on the ≥44px composer). Desktop behavior — including the never-scroll focus contract — is byte-identical.
## Dependencies
-`142_kb_overview_freshness` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The gate (task 01):** a module-level constant beside the existing `reducedMotion` one (app.js ~L391): `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` (the L391 shape — the `typeof` guard keeps the constant safe where `matchMedia` is absent, the house style for the reduced-motion line). The finally block's focus line becomes GATED: `if (!COARSE_POINTER) input.focus({ preventScroll: true });` with a phase-143 comment explaining WHY (the keyboard pop) and the §7.1 tap-target note. The `preventScroll` argument and the never-scroll contract (phase 42 / the `test_turn_end_focus_does_not_scroll` intent) are UNCHANGED on desktop; the error/stop paths take the SAME gate (one focus line, one gate — the finally block is the single settle).
- **The pin (task 01):** `tests/unit/test_frontend_scroll.py::test_turn_end_focus_does_not_scroll` is EXTENDED: the finally block still contains the `preventScroll` focus call (now inside the `if (!COARSE_POINTER)` guard) and the `COARSE_POINTER` constant is defined from `matchMedia("(pointer: coarse)")` — the pin asserts the GATED shape (a bare ungated `input.focus(...)` line in the block is a failure, as today).
- **The E2E (task 02):** dedicated file, two contexts — desktop (default Chromium, no touch) and mobile (a Playwright device descriptor with `isMobile` + `hasTouch` — the descriptor's touch emulation makes Chromium report `pointer: coarse`). Each context: a mock-LLM turn to completion (the house E2E fixture — a single on-topic question, the done state, the same assertions as the chat story files), then assert `document.activeElement` — desktop: the composer's textarea (the phase-06 "always focus back" contract, preserved); mobile: NOT the composer (the keyboard stays down). A sanity assert in the mobile test: `page.evaluate(() => matchMedia("(pointer: coarse)").matches)` is true (the emulation is doing its job — if a future Playwright/Chromium change breaks the emulation, the test fails LOUDLY on the sanity line, not mysteriously on the focus line).
## Tasks
1.`01_coarse_pointer_focus_gate.md` — the constant + the gated focus line + the extended source pin.
2.`02_e2e_mobile_focus.md` — the dedicated two-context E2E.
## Testing & Quality
- Unit: the extended source-level pin (the gated focus shape; the constant definition); every other `app.js` pin passes unchanged.
- E2E: one dedicated Playwright file, run in isolation, mock-only (desktop + mobile contexts).
- Coverage: **>90%** on new/modified code (this phase is frontend — the `app/` coverage gate is unaffected and must stay green).
## Completion Criteria
- [ ] desktop: after a turn ends (done, error, stop), the composer is focused with `preventScroll` — byte-identical to pre-phase
- [ ] mobile (coarse pointer): after a turn ends, the composer is NOT focused — the keyboard stays down; the user's tap starts the next question
- [ ]`uv run pytest` green (the extended pin passes); `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the never-scroll focus contract on desktop, the stop path's single settle, the char counter, the send flow)
# Task 01 — the coarse-pointer gate on the post-turn focus-back
**Phase:**`143_mobile_keyboard_after_answer` · **Source:**`TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The turn's `finally` block re-focuses the composer only on fine-pointer devices: a coarse-pointer (mobile) turn end leaves the page unfocused, so the virtual keyboard does not pop back up over the completed answer.
## Work
1.`frontend/assets/app.js` — beside the existing reduced-motion constant (~L391: `const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;`), add the twin constant: `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` — a short phase-143 comment: the coarse-pointer state of THIS device (touch phones/tablets), the keyboard-pop gate for the turn-end focus-back.
2.`frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): the last line `input.focus({ preventScroll: true });` becomes:
```js
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
// virtual keyboard over the completed answer, which is exactly what
// the owner found annoying. Fine pointers keep the immediate
// refocus (type the follow-up without moving a hand); a coarse
// pointer stays unfocused — the composer is a ≥44px tap target
// (PLAN §7.1), so the next question starts with a tap. The
// never-scroll contract (phase 42) and the single-settle rule are
// unchanged on every device — only the focus call is gated.
if (!COARSE_POINTER) input.focus({ preventScroll: true });
```
The comment above the line ("Focus back for the next question, but never move the viewport…") is folded into the phase-143 comment (one comment block, no stale duplicate). The block's SETTLE lines (clearTurnTimeout / stopThinkingClock / cancelStream / setUiState / the stop status line) are UNTOUCHED — the gate wraps the focus call alone.
- the finally block (located by the existing `// done | error | stop → idle: always settle, always focus back` anchor — KEEP the anchor comment in app.js verbatim or update the anchor in lockstep) now asserts the GATED shape: `if (!COARSE_POINTER) input.focus({ preventScroll: true })` is in the block, and a bare ungated `input.focus(` occurrence (the pre-phase line) is NOT,
- and the constant: `matchMedia("(pointer: coarse)")` appears in the file with the `COARSE_POINTER` binding (the L391-twin shape),
- the docstring is updated to state the phase-143 gate (the pin's job is now: focus-back exists, is preventScroll, and is coarse-gated).
## Testing & Quality
- Unit: the extended pin + every other frontend source pin green (`uv run pytest tests/unit/ -k frontend`).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green: `uv run pytest --cov=app`).
## Completion Criteria
- [ ] desktop (fine pointer): the turn end focuses the composer with `preventScroll` — the phase-06/42 contract byte-preserved
- [ ] coarse pointer: the turn end does NOT focus the composer (the keyboard stays down)
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the settle lines, the error/stop paths, the char counter, the send flow — the gate wraps the focus call alone)
# Task 02 — the dedicated two-context E2E: the keyboard stays down on mobile
**Phase:**`143_mobile_keyboard_after_answer` · **Source:**`TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The browser-visible proof on BOTH devices: a completed turn leaves the composer focused on desktop (the preserved contract) and UNFOCUSED on a mobile-emulated context (the owner's fix) — deterministic, mock-only, in one dedicated file.
## Work
1.`tests/e2e/test_mobile_keyboard_focus.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the SSE/done-state waits from the chat story files):
- a small shared helper in the file: `turn_to_completion(page)` — asks one on-topic question (the fixture's marker question, the mock answers immediately), waits for the done state (the `aria-live` stream settled / the send button back to its idle contract — the same wait the chat story E2Es use).
- test 1 (`test_desktop_turn_end_refocuses_composer`): the DEFAULT Chromium context (no touch — `pointer: fine`): `turn_to_completion` → assert `page.evaluate("document.activeElement")` IS the composer's textarea (locate by the composer's selector — the `input` the app.js module owns; identify it via the existing selector the E2E helpers already use for typing questions). This is the regression guard: the phase-06 "always focus back" contract on desktop.
- test 2 (`test_mobile_turn_end_keeps_keyboard_down`): a MOBILE context — a Playwright device descriptor with `isMobile: true` + `hasTouch: true` (e.g. the built-in Pixel/iPhone descriptor, or an explicit `{ viewport, isMobile, hasTouch }` — the mobile ≤640px CSS breakpoint must also apply, so the descriptor's viewport is the deciding factor): FIRST a sanity assert `page.evaluate("matchMedia('(pointer: coarse)').matches")` is TRUE (the emulation flips the media feature — if a future Playwright/Chromium change breaks that, this line fails loudly and names why, instead of the focus assertion failing mysteriously), THEN `turn_to_completion` → assert `document.activeElement` is NOT the composer's textarea (the keyboard stays down — the owner's complaint is gone).
- both contexts run against the SAME fixture KB and the SAME question — the only variable is the device.
2. Run the regression set in isolation: this file + ONE chat story E2E that exercises the turn-end state (e.g. the phase-06 loading-feedback or the phase-48 stop-generation file — whichever asserts the post-turn UI) to prove the desktop contract survived.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] desktop context: after a completed turn, the composer is the active element
- [ ] mobile context: the `pointer: coarse` sanity assert passes AND after a completed turn the composer is NOT the active element
- [ ]`uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; the chosen chat story E2E still green in isolation
- [ ]`uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)
# Phase 144 — the browser back button closes the document modal, not the view behind it
**Source:**`TODO.md` L6 — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:**`frontend/assets/document-modal.js` — the single owner of `#doc-modal` (the near-fullscreen document viewer; `openDocumentModal` L141+ — trigger memory, the Escape/Tab capture, the `fetchSeq` stale-guard; `closeDocumentModal` — hide, drop the capture, restore focus to the trigger; the close affordances: `#doc-modal-close`, backdrop click, Escape); `frontend/assets/router.js` — the shell's popstate (L283–286: reads the PATHNAME only — a state-only entry's pop is a no-op for it); `frontend/assets/sources.js` — the existing STATE-ONLY-entry convention (L237–260, L1539: `history.pushState({ view: "rag", kb: target }, "")` — the second arg `""` keeps the current URL) and its popstate adopt/reset (L1949–1954: adopts the popped entry's `kb` or resets the drill — a pop onto the SAME state the view already shows is invisible); `tests/unit/test_remove_confirm_modal.py` (the source-level modal-pin precedent); the phase-99 D2 "the browser owns its entries" convention.
**Locked (owner-confirmed 2026-09-30, chat):** scope = the document modal (`#doc-modal`) only — the Sources-page admin confirm dialogs (the `remove-confirm` / `ignore-editor``<dialog>` elements) are quick confirmations, not "modals you look at", and stay as-is. Back closes the modal via a state-only history entry; forward re-opens the modal (the browser owns its entries).
## Objective
On mobile and desktop alike, the browser Back button while the document modal is open CLOSES the modal and leaves the user exactly where they were (same view, same URL, same Sources drill) — instead of navigating the view behind the still-open modal. A Back-then-Forward re-opens the same document; a button/Escape/backdrop close leaves the history exactly as if the modal had never been opened.
## Dependencies
-`143_mobile_keyboard_after_answer` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
The modal becomes history-aware with ONE state-only entry per open — the `sources.js` convention, keyed on a `modal` state key the router (pathname-only) and the sources drill (`kb` key) both ignore:
- **Open** (`openDocumentModal`, after the skeleton guard — the `window.open` fallback path pushes NOTHING): remember the entry we are sitting on (`prev = history.state`), then — IF AND ONLY IF the current entry is not already a modal entry — `history.pushState({ modal: "doc", source, path }, "")` (state-only: the URL does not move). The "not already a modal entry" rule has exactly two callers: the normal open (sitting on the view's entry → push) and the forward re-open below (sitting on the modal's own entry → adopt, no push). If the modal is ALREADY open (defensive — the overlay makes it unreachable by pointer, but the programmatic contract must not stack entries), `replaceState` the existing modal entry in place (the remembered `prev` is kept) instead of pushing.
- **Back** (the popstate handler, armed once at module top level like the other bindings): `popstate` fires with the state of the entry we LANDED ON. The modal is open and the landed state is NOT a `modal` entry → the browser moved off the modal's entry → that is the back press: remember `lastPrev = the landed state` (the entry below the modal's — needed for the forward re-open's restore), close the modal (a `closingViaBack` flag around the call so the close does not also `replaceState`). The router's popstate is a no-op (same pathname → `name === current`); the sources drill re-adopts the SAME state it was already showing (its `kb` key is the state we sat on before the push — invisible). Net: the modal closes, nothing else moves.
- **Forward onto the modal's entry** (modal closed, `event.state.modal === "doc"`): the browser owns its entries (phase-99 D2) — re-open the document the entry names: `openDocumentModal(st.source, st.path, null)` (trigger null — focus goes to the content target, the normal open path). The open sees the current entry IS a modal entry → ADOPTS it instead of pushing: `entry = { prev: lastPrev, source, path }` (`lastPrev` — the below-entry remembered at the back-close; it is always available: a modal entry can only be in the forward stack because a back-close popped it first).
- **Button / Escape / backdrop close** (not a back press): the modal entry is still the current entry — remove it IN PLACE with `history.replaceState(entry.prev, "")` (no popstate fires, no other listener runs, the view state is restored byte-for-byte) — so the NEXT back behaves exactly as if the modal had never been opened. Guard: run the `replaceState` only when `history.state` is still a modal entry (the invariant — the three affordances are reachable only while open, and open means sitting on the modal entry; the check is the fail-safe if the invariant ever breaks: do nothing to the history rather than corrupt the current entry).
- Module state: `let entry = null` — `{ prev, source, path }` while the modal is open (the `prev` to restore, the doc the entry names); `let lastPrev = null` — the below-entry remembered at each back-close (the adopt's `prev`); both `null`/stale otherwise. The popstate handler and the open/close paths are the ONLY writers. Known edge (documented, accepted): if the user opens the modal, backs out, switches views, then FORWARDS onto the modal entry, the modal re-opens over the new view — the browser owns its entries (the sources drill does the same with its `kb` entries); the modal is always one close-away.
## Tasks
1.`01_history_aware_modal.md` — the push/replace/popstate mechanics in `document-modal.js` (open, back, forward, the three close affordances).
2.`02_unit_pins.md` — the source-level unit pins (the `test_remove_confirm_modal.py` pattern) for the entry lifecycle and the listener interplay.
3.`03_e2e_back_closes_modal.md` — the dedicated E2E: back closes the modal (URL + view + drill untouched), button-close keeps history sane, forward re-opens.
## Testing & Quality
- Unit: the source-level pins (the push on open, the `closingViaBack`-guarded close, the `replaceState(prev)` on non-back close, the popstate handler's two branches, the re-open suppression).
- E2E: one dedicated Playwright file, run in isolation, mock-only — `page.goBack()` / `page.goForward()` drive the real history.
- Regression: `tests/e2e/test_document_back_navigation.py` (the phase-13 /document.html back-nav story — the FULL-PAGE viewer, a different page: it must stay green) and the phase-99 Sources drill E2E (the kb back/forward story — the interplay must not leak).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] with the modal open: Back → the modal closes, the URL is unchanged, the view is unchanged, the Sources drill (when opened from Sources) is unchanged; a SECOND Back then leaves the view as usual
- [ ] Back → Forward → the same document re-opens in the modal (no duplicate history entry)
- [ ] button / Escape / backdrop close → the next Back behaves exactly as if the modal had never been opened (no dead no-op back step)
- [ ]`uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; the two regression E2Es still green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the router's popstate, the sources drill's adopt/reset, the modal's focus trap / Escape / fetch-seq contracts, the admin confirm dialogs)
**Phase:** `144_modal_back_button` · **Source:**`TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
`#doc-modal` owns one state-only history entry per open: Back closes the modal (view/URL/drill untouched), a non-back close removes the entry in place (the next Back behaves as if the modal never opened), and Forward onto the modal's entry re-opens the same document.
## Work
1. `frontend/assets/document-modal.js` — module state beside the existing `triggerEl` / `fetchSeq` lets: `let entry = null;` (`{ prev, source, path }` while the modal is open — `prev` is the below-entry to restore, `source`/`path` are the doc the entry names) and `let lastPrev = null;` (the below-entry remembered at each back-close — the forward re-open's restore source). Comment each on the `fetchSeq` house style (what it is, who writes it).
2. `frontend/assets/document-modal.js` — `openDocumentModal` (L141+), after the skeleton guard (the `window.open` fallback stays BEFORE this — it pushes nothing) and before `modalEl.hidden = false`:
```js
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
* Back closes the modal instead of navigating the view behind it.
* The entry is state-only (second arg "" — the URL does not move),
* so the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing (the sources.js
* state-only-entry convention, L1539). The current entry is a modal
* entry only on the forward re-open (the popstate handler below
* calls us sitting on it) — then we ADOPT it; otherwise we push. */
const st = history.state;
if (entry) {
// Defensive re-open while open: replace our entry in place (the
// remembered prev is kept) — never stack a second entry.
(The rest of the open body — trigger memory, `showLoading`, the Escape/Tab capture, the fetch — is untouched.)
3. `frontend/assets/document-modal.js` — `closeDocumentModal` (L~200): at the top (after the existing `if (!modalEl || modalEl.hidden) return;`), before the hide, add the entry removal:
```js
/* Phase 144: a NON-back close removes the modal's entry IN PLACE —
* no popstate fires, no other listener runs, the view state is back
* byte-for-byte, so the next Back behaves as if the modal never
* opened. A back close (closingViaBack — the popstate handler) has
* already been moved off the entry by the browser: no replace. */
with `let closingViaBack = false;` beside the other lets (reset by the popstate handler after each close call — it is set ONLY around that call).
4. `frontend/assets/document-modal.js` — the popstate listener, armed ONCE at module top level (next to the existing `closeEl`/`backdropEl` bindings — a missing skeleton means the listener is a no-op via the `entry`/`modalEl` guards, the header.js pattern):
* fires with the state of the entry we LANDED on. Back: the modal is
* open and we landed on a non-modal state — the browser moved off our
* entry; close the modal (remember the landed state as lastPrev for
* the forward re-open; closingViaBack keeps the close from also
* replaceState-ing). Forward: the modal is closed and we landed on a
* modal entry — the browser owns its entries (the phase-99 D2
* convention) — re-open the doc the entry names (the open adopts the
* entry — no second push). Every other popstate (the router's view
* entries, the drill's kb entries) passes through untouched. */
window.addEventListener("popstate", (event) => {
const st = event.state;
const isModalEntry = st && st.modal === "doc";
if (isModalEntry && modalEl && modalEl.hidden) {
openDocumentModal(st.source, st.path, null);
return;
}
if (!isModalEntry && modalEl && !modalEl.hidden && entry) {
lastPrev = st;
closingViaBack = true;
closeDocumentModal();
closingViaBack = false;
}
});
```
5. Update the module's header docstring contract block (the `openDocumentModal` / `closeDocumentModal` bullets, L12–20): one bullet for the history behavior (the entry, Back closes, the in-place removal, the forward re-open) — the file's self-documentation convention.
- ASSUMPTION: the state key is `modal: "doc"` (a string discriminator, not a unique token) — a second modal type would use its own key (the `kb` / `view` key convention); the value pair (`source`, `path`) rides in the state so the forward re-open names its document (percent-encoding is irrelevant — the state is structured, not a URL).
- ASSUMPTION: `lastPrev` (not `history.state`) is the adopt's `prev` — at a forward re-open the browser sits ON the modal entry, so the below-entry is only the one remembered at the back-close; a modal entry can reach the forward stack only through a back-close, so it is always available (the null case — e.g. a forward after a hard page reload into a modal URL — is impossible: modal entries are same-document pushes, never URL-deep-links).
## Testing & Quality
- Unit: task 02's source-level pins (this task ships with them green — the pins are written against the final shape above).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] open → one state-only entry (URL unchanged); re-open-while-open → in-place replace, still one entry
- [ ] Back with the modal open → the modal closes, `lastPrev` remembered, no `replaceState` (the browser owns the pop)
- [ ] button/Escape/backdrop close → `replaceState(entry.prev)` runs (the entry is gone in place); the next Back is a real navigation step, not a dead no-op
- [ ] Forward onto the modal's entry → the same document re-opens, no second push
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the router's popstate, the sources drill, the modal's focus trap / Escape / fetch-seq / trigger-focus contracts — the new state sits beside them)
# Task 02 — the source-level pins: the entry lifecycle, the two popstate branches, the no-leak guarantees
**Phase:** `144_modal_back_button` · **Source:**`TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
A silent regression in the history mechanics (a double push, a replaceState on the wrong entry, a listener leak, a lost `lastPrev`) is caught at the source level — the `test_remove_confirm_modal.py` house pattern for a frontend module whose real behavior is E2E-gated.
## Work
1. `tests/unit/test_frontend_doc_modal.py` — new file (the `test_remove_confirm_modal.py` pattern: read `frontend/assets/document-modal.js` as text — the `_js()`-style loader, REPO-relative path; if a doc-modal unit file already exists, extend it instead):
- **the state:**`let entry = null` and `let lastPrev = null` and `let closingViaBack = false` are declared exactly once (one `let` each — no second writer site);
- **the open:** the push is `history.pushState({ modal: "doc", source, path }, "")` (state-only — the `""` second arg present — the URL must not move); the push sits INSIDE the else-branch behind the `st && st.modal === "doc"` adopt check (assert the adoption branch assigns `entry = { prev: lastPrev, source, path }` WITHOUT a pushState — the no-second-push rule); the defensive re-open branch uses `history.replaceState` and keeps `entry` (updates `source`/`path`);
- **the close:** the in-place removal `history.replaceState(entry.prev, "")` is guarded by `!closingViaBack` AND `history.state && history.state.modal === "doc"` (the fail-safe — a non-modal current entry is never overwritten); `entry = null` runs unconditionally in the close (the adopt's memory must not survive a close);
- **the popstate:** the listener is registered ONCE (one `window.addEventListener("popstate"` in the file); its two branches are mutually exclusive (the `isModalEntry && modalEl.hidden` forward-re-open branch RETURNS before the back-close branch); the back-close branch writes `lastPrev = st` BEFORE the `closingViaBack`-wrapped `closeDocumentModal()` call; no branch fires when `entry` is falsy / the modal is closed and the state is not a modal entry (the pass-through);
- **no leak:** the popstate listener and the entry state are the ONLY `history.*` call sites in the file (a grep-style assert: the file's `history.` occurrences are exactly the push, the two replaces, and nothing else — `history.back()` / `history.go()` must NOT appear — the browser owns its entries);
- **the contract unchanged:** the pre-phase pins that a regression could plausibly break still hold — the Escape → `closeDocumentModal()` capture, the `fetchSeq` stale-guard lines, the trigger-focus restore in the close, the `window.open(fullPageUrl…)` fallback BEFORE the entry logic (the fallback pushes nothing — assert its early `return` precedes the first `history.` use in the open).
2. Run the frontend unit set: `uv run pytest tests/unit/ -k frontend` green (this file + every pre-existing pin).
## Testing & Quality
- Unit: the pins above (each one names the regression it catches in its docstring — the house pattern).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] `tests/unit/test_frontend_doc_modal.py` green; every pre-existing frontend unit pin green (`uv run pytest tests/unit/ -k frontend`)
- [ ] the pin set catches: a second push on forward re-open, a `replaceState` on a non-modal entry, a double-registered popstate listener, a `history.back()`/`go()` introduction, a lost `lastPrev` write, a fallback-path push
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (this task is tests-only)
# Task 03 — the dedicated E2E: Back closes the modal, the history stays sane, Forward re-opens
**Phase:** `144_modal_back_button` · **Source:**`TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
The browser-visible proof with the REAL history: `page.goBack()` / `page.goForward()` drive the owner's exact scenario — back closes the modal (nothing else moves), a button close leaves no dead step, forward re-opens the document.
## Work
1. `tests/e2e/test_modal_back_button.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the chat-chip / sources-table open paths from the phase-26 story files):
- fixture: the standard E2E KB (at least one readable document); a helper `open_modal_via_chip(page)` — asks a question whose answer carries a source chip (the mock's citation echo), clicks the chip, waits for `#doc-modal` visible + the content rendered (the "Document loaded." desc state);
- test 1 (`test_back_closes_modal_view_unchanged`): boot on `/` (chat), `open_modal_via_chip`, note `page.url` (`/`) and the visible view (`#view-chat` not hidden) → `page.goBack()` → assert `#doc-modal` is hidden (closed), `page.url` STILL `/`, `#view-chat` still visible (the view behind was NOT navigated — the owner's confusion is gone) → one more `page.goBack()` is a REAL step: it leaves the app's shell history (in the test, assert the URL moved away from `/` or the page left the shell — the point: the second back did the navigation the first back should have done; if the test boots with no prior history, skip the second-back assert and note it — the modal entry's existence is proven by test 3's forward);
- test 2 (`test_button_close_leaves_no_dead_step`): boot, `open_modal_via_chip` → close via `#doc-modal-close` (the button affordance) → assert the modal hidden → assert the history depth is RESTORED: `page.evaluate("history.length")` equals the pre-open depth (the in-place `replaceState` removed the entry — the next Back is a real navigation step, not a silent no-op). (If the boot context's `history.length` baseline is flaky across Chromium versions, fall back to the OBSERVABLE: a `page.goBack()` after the button close leaves `/` immediately — one step, not two — and assert that instead; the depth assert is preferred when stable.);
- test 3 (`test_forward_reopens_same_document`): boot, `open_modal_via_chip` (document D) → `page.goBack()` (modal closes) → `page.goForward()` → assert `#doc-modal` visible again with D's title (the same document — `#doc-modal-title` text equals the pre-back title) and the history did NOT gain an entry (`history.length` unchanged by the re-open — the adopt, not a push);
- test 4 (`test_back_from_sources_keeps_drill` — the interplay guard): boot on `/sources.html` (the RAG view, admin — the login helper), open a document via a table link (the drill at the TOP level is the simplest case), `page.goBack()` → modal hidden, URL `/sources.html`, the Sources view still visible; if the fixture supports a drilled level (a folder with the phase-99 breadcrumb), open the document FROM a drilled level and assert the breadcrumb survived the back (the drill's popstate re-adopted its own state — no visible change).
2. Run the regression set in isolation (each its own run): this file; `tests/e2e/test_document_back_navigation.py` (the phase-13 FULL-PAGE /document.html back-nav — a different page, must stay green); the phase-99 Sources drill E2E (`test_kb_tree_table_and_back_nav.py` — the `kb` entry interplay must not leak).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: the two named files green in isolation.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] back with the modal open → modal closed, URL unchanged, view unchanged (tests 1 + 4)
- [ ] button close → no dead history step (test 2)
- [ ] back → forward → the same document re-opens without a new history entry (test 3)
- [ ] `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; both regression E2Es green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)
# Phase 145 — the answer points at the original git source: the model links it, the modal shows it
**Source:** `TODO.md` L7 — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/agent.py` — the read branch's result shapes (~L1630–1680: `Document {source}/{path}:\n` + `date: …\n` + image marker + content; the `E2E mock`'s `_READ_RESULT_PREFIX` = the FIRST line only — `tests/e2e/mock_llm.py` L1412 — so a new line BELOW the first line keeps the mock contract); `app/models.py` — `GitSource` (`url` L289, `kind` L292, `token` L316 — the PAT, never served, `branch` L333 — phase 135, NULL = default branch); `app/rag/git_sources.py` — `sanitize_url` (L~150: the phase-121 credential-free output mask — strips userinfo, byte-identical for clean URLs) and `clone_url_for` (the single credential-injection site); `app/schemas.py` — `DocContent` (the `GET /api/documents/content` payload — the phase-106 `created_at` field is the recent optional-metadata precedent) and `SourceRef` (UNCHANGED this phase — the done frame stays byte-identical, the origin rides the content payload instead); `app/api/docs.py` — the content endpoint (the `(source, path)` lookup, percent-encoding); `frontend/assets/document.js` — `renderDocument` (L172+: the shared meta-badge row — `metaBadge`/`pathCode` idioms — used by BOTH the doc modal and the full page); `app/rag/prompts.py` — the `<tools>` section (L162+: the `ls`/`read`/`grep` teaching, HIGH prompt only); `app/rag/retriever.py` — `source_ref_with_image` (untouched).
**Locked (owner-confirmed 2026-09-30, chat):** the model learns the origin from a new `origin:`/`branch:` line in read results (git sources only, credentials stripped) and is taught to include the original-source link in its answer — it builds the browse URL itself (no fragile server-side GitHub/Gitea URL construction); the doc modal + document page get the "Original" external link (one `renderDocument` site, fed by the content payload); local (non-git) sources get no link.
## Objective
An answer grounded in a git-sourced document comes with the way to the complete original in ONE step: the read result tells the model the repository origin (and pinned branch), the prompt teaches it to include the original-source link in its answer text (so it works in the UI AND through the API tokens), and the document modal + full page show the same origin as a visible "Original" external link.
## Dependencies
- `144_modal_back_button` (todo) — pipeline order only; code touch (the modal's meta row) is the same `renderDocument` surface, no ordering risk.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The origin resolver (task 01):**`app/rag/git_sources.py` (the GitSource-domain home) — `origin_for_source(db, source_name) -> tuple[str, str | None] | None`: the source's directory basename (the importer's `source = root.name`) mapped to a `kind = "git"` row by the row's URL basename — the URL's path component, trailing `/` stripped, `.git` suffix stripped, last path segment (scp-style `git@host:owner/repo.git` → the part after `:` first; `sanitize_url` applied to the result so a legacy embedded-credential row never leaks; `branch` returned as-is — NULL means the default branch). EXACTLY ONE match → `(sanitized_url, branch)`; zero or TWO-OR-MORE matches (two repos with the same basename) → `None` (no line — never guess; the ambiguity is logged at WARNING with the sanitized names). No caching (the table is a handful of rows; a read's extra query is trivial).
- **The read result (task 01):** a git-sourced document's read result gains, IMMEDIATELY AFTER the `date:` line (before the image marker — the marker keeps its phase-122 position relative to the content): `origin: {sanitized_url}` and, when a branch is pinned, `branch: {branch}` — one line each, no URL for local sources, no lines at all for a source with no unambiguous git row (the result is byte-identical to pre-phase for every non-git doc — the existing pins pass unchanged; the mock's `_READ_RESULT_PREFIX` first-line contract is untouched — the new lines sit BELOW the header). The plain-read, truncated-plain, range-read, and truncated-range shapes all carry the lines at the same position (one insertion helper — the four result builders share it).
- **The teaching (task 02):** the `<tools>` section gains the citation-source rule (the `read` teaching's natural home — the model answers from what it READ): when an answer uses a document whose read result carries an `origin:` line, the answer INCLUDES the original source — a link to the repository (the `origin` line) with the document's path (from the `Document …` header) named, and the pinned branch when one is shown — formatted as the repository's file-view URL where the model can tell it (GitHub/Gitea/GitLab `…/blob/<branch>/<path>`), else the repo URL + path as text. The point (the owner's words, in the model's language): the user should get the complete source at once — one link, not a second question. Local-sourced documents carry no `origin` line — nothing to add. The `SUGGEST_INTRO` citation discipline (cite only what you read) is UNCHANGED — this rule extends the cited documents' answers, it does not widen the cite set.
- **The UI (task 03):**`DocContent` (the content payload) gains `origin_url: str | None = None` + `origin_branch: str | None = None` (the resolver's output for the document's source — NULL/absent for local sources, the `image_url`-style optional-metadata shape; a null field in the content payload is fine — the omission rule binds the SSE frames, not this payload). `renderDocument` (document.js L172+) — when `doc.origin_url` is present, the meta row gains, after the path code: an "Original" anchor (`<a>` — `createElement`, the never-build-HTML rule; `href = doc.origin_url`, `target="_blank" rel="noopener"` — the modal's "Full page" escape-hatch idiom; `textContent = doc.origin_url` VERBATIM — the copyable-link contract — in the `doc-path` ellipsis style, the full URL in a `title` attribute that also carries ` — branch: <name>` when `origin_branch` is present) — ONE site: the modal AND the full page show it (both call `renderDocument`).
- **The E2E (task 04):** a git row seeded over the fixture source's name (the resolver is name-based — a `kind="git"` row whose URL basename matches the fixture source name makes the fixture docs "git-sourced" for this phase's purposes), a scripted mock flow whose final answer carries the repo URL, and the modal's meta anchor asserted.
## Tasks
1. `01_origin_resolution.md` — the resolver + the read-result `origin:`/`branch:` lines (the four read shapes).
2. `02_prompt_teaching.md` — the `<tools>` original-source rule + the prompt pins.
3. `03_content_payload_render_link.md` — `DocContent` fields + the content endpoint + the `renderDocument` "Original" anchor (modal + full page).
4. `04_e2e_source_link.md` — the dedicated E2E (the answer carries the repo link; the modal shows the Original anchor; non-git docs stay byte-identical).
## Testing & Quality
- Unit: the resolver (the mapping, the sanitization, the ambiguity, the scp-form URL, the `.git` suffix, the no-row case); the read-result insertion (four shapes × {git, git+branch, local, ambiguous} — non-git byte-identical pins); the prompt copy pin; the payload/endpoint; the renderer (the anchor present/absent, the branch label, the ellipsis title).
- Integration: the content endpoint returns `origin_url` for a seeded git row (and null for a plain local source).
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] a git-sourced read result carries `origin: <sanitized url>` (+ `branch:` when pinned); every local-source read result is byte-identical to pre-phase
- [ ] the HIGH prompt's `<tools>` section teaches the original-source link rule (pinned verbatim); the LOW prompt is byte-identical
- [ ] the doc modal AND /document.html show the "Original" external link for git-sourced docs (the branch in the label when pinned); nothing new for local docs
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the read header's first line, the mock's parse, the done frame, the LOW prompt, the non-git read results)
# Task 01 — the origin resolver + the read-result `origin:`/`branch:` lines
**Phase:** `145_git_source_links` · **Source:**`TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use…"
**Story:** n/a
## Objective
A read result for a git-sourced document tells the model where the complete original lives — the sanitized repository URL (and the pinned branch, when one exists) — so the model can link it in its answer (task 02's teaching). Every non-git read result is byte-identical to pre-phase.
- the candidate rows: `select(GitSource).where(GitSource.kind == "git")` (local rows are never origins — nothing to link to from the user's browser);
- the match: the row's URL basename == `source_name` — the URL's path component (after the `://`, or after `:` for scp-style `git@host:owner/repo`), trailing `/` stripped, a single trailing `.git` suffix stripped, last segment taken;
- EXACTLY one match → `(sanitize_url(row.url), row.branch)` — the phase-121 output mask keeps a legacy embedded-credential row's secret out of the prompt (the `clone_url_for` lesson: the stored URL may carry userinfo; the stored value is never modified);
- zero matches, or TWO-OR-MORE (two repos sharing a basename) → `None` + a WARNING log naming the sanitized candidates (never guess — the read result would otherwise carry a wrong origin into every answer);
- pure-DB helper (no git subprocess — `scripts/git_sync.py` stays the only git-invocation site, PLAN §13).
2. `app/rag/agent.py` — the read branch: after the doc resolves (all FOUR successful result builders — plain at-or-under-cap ~L1655, truncated plain ~L1636, range at-or-under-cap ~L1686, truncated range ~L1668), compute `origin = origin_for_source(db, doc.source)` ONCE per call (before the branching on ranged/truncated — the lines are identical in all four shapes) and insert IMMEDIATELY AFTER the `date:` line and BEFORE the image marker: the text-doc shape becomes `Document …: / date: / origin: / branch: / content`, the image-doc shape `Document …: / date: / origin: / branch: / marker / content` (the marker keeps its phase-122 position relative to the content — the origin lines ride above it). One line `origin: {url}` and, when `branch` is non-NULL, one line `branch: {branch}`. `origin is None` → NO lines (byte-identical to pre-phase). A shared tiny helper builds the two lines; the four result builders each call it once with the same strings.
3. `tests/unit/test_agent.py` (or the read-branch unit home) — the matrix: {git row matched, git row + pinned branch, local source (no git row), two rows same basename (ambiguous)} × the four read shapes → the exact result strings (the origin lines present at the exact position for the git cases, ABSENT for local/ambiguous); the first line of every result is still `Document {source}/{path}:` (the mock's `_READ_RESULT_PREFIX` contract — assert it); the image-doc shape keeps its marker position (the phase-122 pin).
4. `tests/unit/` — the resolver's own unit file (the git_sources unit home — `tests/unit/test_git_sources*.py` if it exists, else a new one): the basename mapping (`.git` suffix, trailing `/`, scp-style `git@host:owner/repo.git`, an `@` inside a PATH — not userinfo — stays), the `sanitize_url` application (a legacy `https://user:tok@host/x.git` row → the prompt line is `https://host/x.git`), the zero-match and two-match `None` + the WARNING (caplog), the `branch` passthrough (NULL → None → no `branch:` line).
## Testing & Quality
- Unit: the resolver matrix + the four-shape read-result matrix (above); every pre-existing read-branch pin passes UNCHANGED (non-git byte-identity is the point).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] a git-sourced read result carries `origin: <sanitized url>` (+ `branch: <name>` when pinned) in all four read shapes, at the documented position; the first line is untouched
- [ ] local-source and ambiguous-basename read results are byte-identical to pre-phase (the existing pins prove it)
- [ ] no credential ever reaches a read result (the legacy embedded-token case is pinned sanitized)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the grep/ls surfaces, the refusal lines, the `read_docs`/`read_truncations` signals, the SSE frames)
# Task 02 — the `<tools>` teaching: the answer includes the link to the original source
**Phase:** `145_git_source_links` · **Source:**`TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a
## Objective
The model that answers from a git-sourced document includes, in the answer text, the link to the complete original — repository (its read result's `origin` line), the file's path (the `Document …` header), and the pinned branch when one was shown — so the user gets the source in one step, in the UI AND through the API tokens (the answer is the only surface both share).
## Work
1. `app/rag/prompts.py` — `TOOLS_SECTION` (L216+): append the original-source rule to the `read` clause (it is the read's contract — the `origin:` line is a read-result fact, and the rule binds the ANSWER that uses what the read added). Locked copy (pinned verbatim by the tests):
`A read result for a document from a git source carries an origin line (and a branch line when a branch is pinned): when your answer uses such a document, include the original source in the answer — a link to the repository from the origin line, with the document's path (from the Document header) named and the pinned branch when one is shown — as the repository's file-view URL where you can tell it (for example <repo-url>/blob/<branch>/<path>), or the repository URL plus the path as plain text otherwise. The user should be able to open the complete original from your answer in one step, not ask again for the source.`
Position: after the truncation workflow sentence (the `read` clause's end), BEFORE the `grep` clause begins — one appended sentence-group, the rest of the section byte-identical. The rule is CONDITIONAL on the `origin` line's presence — a local-sourced document's read result carries none, so the rule adds nothing to those answers (the model has no URL to cite; it must NOT invent one — the sentence's conditioning does that job; if a live run ever shows a fabricated origin, the phase-72-style teaching refusal is the follow-up, not this phase).
2. The `AGENT_TOOLS``read` description (app/rag/agent.py L331+ — the most local text at call time, the phase-70 contract) gets the ONE-clause mirror: "For a document from a git source, the result also carries an `origin` line (and `branch` when pinned) — include that original source in your answer (see your tools instructions)." (the behavior detail stays in `TOOLS_SECTION` — the description points, the section teaches; the phase-124 `show_image` description's "see the instructions" shape is the precedent if the executor prefers a shorter pointer).
3. `tests/unit/` — the prompt pins (the HIGH-prompt suite that pins `TOOLS_SECTION`):
- the new rule pinned verbatim (the exact sentence-group, at its exact position — between the truncation sentence and the `grep` clause),
- the section's OTHER bytes byte-identical (a diff-style pin: the pre-phase section with ONLY the new sentence-group inserted),
- the LOW/deflect prompt stays byte-identical (the `<tools>` section is HIGH-only — the existing pin passes unchanged),
- the mock is untouched (it keys off the `<tools>` marker's presence — assert the marker line is unchanged),
- the `read` tool description pin updated with the new clause (the `AGENT_TOOLS` schema pins).
4. `SUGGEST_INTRO` (L152) and the citation discipline are UNCHANGED (the rule extends the answers of cited documents; it does not widen the cite set — the "cite only what you read" sentence passes byte-identical).
## Testing & Quality
- Unit: the pins above; every pre-existing prompt pin passes (the LOW prompt, the section order, the `DEFLECT_MODE` marker, the `SUGGEST_INTRO` copy).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the HIGH prompt's `read` clause carries the original-source rule (pinned verbatim, exact position); the tool description carries its pointer clause
- [ ] the LOW prompt is byte-identical; the mock's marker keying is untouched
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the section order, the deflect path, the citation discipline, the other tool clauses)
# Task 03 — the "Original" link: `DocContent` origin fields + the shared `renderDocument` anchor
**Phase:** `145_git_source_links` · **Source:**`TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use…"
**Story:** n/a
## Objective
Every surface that shows a document (the chat's doc modal AND the full /document.html page — one `renderDocument` site) shows the git origin as a visible, copyable "Original" external link — fed by the content endpoint's new origin fields. Non-git documents show nothing new (byte-identical meta row).
## Work
1. `app/schemas.py` — `DocContent` (L531+): `origin_url: str | None = None` and `origin_branch: str | None = None` — the `image_url`-style optional metadata (L557+): ABSENT from the wire when `None` (the `_drop_absent_share_url` omission precedent — the serializer drop, never `null`), so a non-git document's payload is byte-identical to pre-phase. `origin_url` is the sanitized clone URL (credential-free — the resolver's output, task 01's `sanitize_url` guarantee); `origin_branch` is the pinned branch (absent when the row pins none — NULL = the repo's default branch, nothing to show).
2. `app/api/docs.py` — `get_document_content` (L165+): after the document resolves, `origin = origin_for_source(db, doc.source)` (task 01's resolver — the SAME function the read branch uses, one mapping for the whole app) → set the two payload fields (`None` → absent on the wire). The endpoint's percent-encoding, 404, and image bytes are untouched.
3. `frontend/assets/document.js` — `renderDocument` (L172+): when `doc.origin_url` is present, the meta row gains, AFTER the path `code` and BEFORE the Created badge: an "Original" anchor — `createElement("a")` (the never-build-HTML rule), `className "doc-origin"` (a new class in `styles.css` mirroring the `doc-path` code style — ellipsis clip, the house dark-tech palette tokens only, `target=_blank` affordance color from the existing link tokens), `href = doc.origin_url`, `target="_blank"`, `rel="noopener"` (the modal's "Full page" escape-hatch idiom — index.html L1579), `textContent = doc.origin_url` — the full URL VERBATIM as the visible text (the copyable-link contract: no decorative label inside the anchor text — a ` (branch)` suffix would break copy-paste; the CSS ellipsis clips the display, and the `title` attribute carries the full URL, the `pathCode` precision idiom — plus, when `doc.origin_branch` is present, ` — branch: <name>` appended: `title = doc.origin_url + " — branch: " + doc.origin_branch`). `doc.origin_url` absent (the field simply missing on the wire for non-git docs) → the meta row is byte-identical to pre-phase (the badge row unchanged — the existing `renderDocument` pins pass as-is).
4. `frontend/assets/styles.css` — the `.doc-origin` class (the `doc-path` idiom: `code`-family font, ellipsis `white-space: nowrap; overflow: hidden; text-overflow: ellipsis`, the ≤640px row behavior mirroring the badge row's wrap rules — the meta row already wraps on mobile, the anchor is one more inline item).
5. `tests/unit/test_document_viewer.py` (the `renderDocument` pin home — or its neighbor per the existing layout) — the anchor: present + correct `href`/`target`/`rel`/`text`/`title` for a git doc (branch and no-branch variants); ABSENT (the meta row's exact pre-phase children) for a doc without `origin_url`; the never-build-HTML shape (no innerHTML/outerHTML in the new code).
6. `tests/integration/` — the content endpoint: a `kind="git"` row seeded with a URL basename matching a fixture source → `GET /api/documents/content` carries `origin_url` (+ `origin_branch` for a pinned row); a plain local fixture source → the field is ABSENT from the JSON (not `null` — the omission pinned on the wire).
## Testing & Quality
- Unit: the renderer pins (above) + the endpoint integration (above); every pre-existing `renderDocument` / content-endpoint pin passes unchanged (the non-git byte-identity).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the doc modal AND /document.html show the "Original" anchor (repo URL as text, `_blank` + `noopener`, the branch in the `title` when pinned) for git-sourced documents
- [ ] a non-git document's content payload and meta row are byte-identical to pre-phase (the field absent on the wire, the badge row unchanged)
- [ ] no credential in the payload (a legacy embedded-token URL is pinned sanitized — the resolver's guarantee, end-to-end)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the viewer's summary panel, the date editor, the image block, the done frame, the SSE contract)
# Task 04 — the dedicated E2E: the answer carries the repo link, the modal shows the Original anchor
**Phase:** `145_git_source_links` · **Source:**`TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a
## Objective
The browser-visible proof of the owner's scenario: a question about a git-sourced document produces (1) an answer that contains the repository link (the model-side teaching, through the real read result that carries the `origin:` line) and (2) a document modal whose meta row carries the "Original" external anchor. A local-sourced document produces neither (the byte-identity guard).
## Work
1. `tests/e2e/mock_llm.py` — ONE scripted flow, house style (module-header docstring entry):
- marker: the user message contains `original source link`;
- request 1: a `read` tool call for the fixture document (the flow's doc — its `origin:` line is added by the REAL server when the seeded git row matches — the mock does not fabricate it; the flow's request-2 keying must tolerate the extra line(s) between the `date:` line and the content — the mock's read-result parsing keys off the FIRST line (`_READ_RESULT_PREFIX`, L1412), so the new lines need no mock change; if the flow's echo shape copies a fixed slice of the result, the executor verifies the echo still lands on content, not the origin line, and adjusts the EOOCH slice if it does);
- request 2 (the read result present): the final answer — a DETERMINISTIC string containing the seeded repository URL verbatim (e.g. `The full source lives at https://example.com/reese/skills — see the repo for the complete original.` — the mock is scripted, so the "model includes the link" behavior is asserted on the scripted echo, while the SERVER-side half — the `origin:` line actually reaching the mock — is asserted on the request the server SENT: the E2E inspects nothing of the prompt directly (the mock records nothing), so the origin-line assertion goes through the read RESULT's surface: the SSE `tool_result`/tool frame shape the UI renders for the read — the phase-117 compact tool surface — which carries the result text; assert the rendered tool output contains `origin: https://example.com/reese/skills`).
2. `tests/e2e/test_git_source_links.py` — dedicated Playwright file, run in isolation, mock-only (the house conventions — the per-module fixture import + the git-source row seeding in the test's own DB session, the login helper, the SSE frame collection from the chat story files):
- fixture seeding: the standard E2E KB (one source, one readable doc) PLUS a `GitSource` row — `kind="git"`, `url="https://example.com/reese/skills"` (public form — no credential), `branch=None` for test 1 and `branch="main"` for the pinned-branch test — whose URL basename (`skills` after the `.git`-less path) MATCHES the fixture source's name (the resolver's name mapping — the fixture source is locally imported, but the mapping is name-based, which is the point of the seam); a SECOND plain local source (no git row) is the byte-identity control;
- test 1 (`test_answer_carries_origin_link`): ask the `original source link` question on the git-matched source's doc → collect the SSE → assert the rendered tool output for the `read` carries `origin: https://example.com/reese/skills` (the server put the line in the result the model saw) and the final `done` answer contains the repository URL (the scripted echo — the model-side contract, deterministic);
- test 2 (`test_modal_shows_original_anchor`): same setup → click the source chip → `#doc-modal` rendered → the meta row contains the `.doc-origin` anchor with `href="https://example.com/reese/skills"`, `target="_blank"`, `rel="noopener"`, `textContent` = the URL verbatim (and NO branch in the text — `branch=None`);
- test 3 (`test_pinned_branch_in_title`): the `branch="main"` row → the same anchor's `title` is `https://example.com/reese/skills — branch: main`;
- test 4 (`test_local_source_has_no_origin`): the control source's doc → the read's rendered tool output carries NO `origin:` line (byte-identical result shape) and the modal's meta row has NO `.doc-origin` anchor (the pre-phase badge row).
3. Run the regression set in isolation: this file + `tests/e2e/test_read_line_ranges.py` (the read-result shape contract through the mock) + `tests/e2e/test_document_viewer.py` (the `renderDocument` meta row).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: the two named files green in isolation.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] the read's tool output carries the sanitized `origin:` line (git-matched source) and the done answer carries the repository URL (tests 1–3)
- [ ] the modal's meta row shows the "Original" anchor (URL verbatim text, `_blank`+`noopener`, the pinned branch in the `title` — tests 2–3)
- [ ] the local-source control is byte-identical: no `origin:` line in the read result, no anchor in the meta row (test 4)
- [ ] `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` green in isolation; the regression E2Es green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the mock's first-line parsing, the done frame, the local-source payloads/results)
# Phase 146 — the search tool line shows WHERE the LLM is searching: a scoped grep carries its document
**Source:** `TODO.md` L3 — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/agent.py` — the `grep` tool definition (~L395–441: required `pattern` + optional `path` — "Rarely needed — only for re-searching one document you already know: that document's combined `source/path` identity"); `app/api/chat.py` — the `ToolCallPiece` → SSE `tool` frame builder (~L909–947: `argument = piece.arguments.get("pattern" if piece.name == "grep" else "path")` — a scoped grep's `path` argument is DROPPED today, so every search line renders the same pattern); `app/schemas.py` — `ChatToolEvent` (L182: `{name, argument, offset?, limit?}` — the additive-omission serializer precedent is `DocContent._serialize` dropping `image_url` when None); `frontend/assets/app.js` — `appendToolLine` (~L1122: the grep branch renders `🔎 Searching for <code>pattern</code>` — no scope, byte-identical for bare and scoped calls), the live tool-frame handler (~L3178–3230: `ev.offset`/`ev.limit` additive wire fields + the `toolRecord` stamp — the exact pattern this phase extends with `ev.path`), the restore path (~L2064–2086: re-renders saved `{name, argument, offset?, limit?}` records through the same helper); `tests/e2e/mock_llm.py` — the scripted-turn idiom (the phase-70/72 `grep` scripted branches — the house way of driving a model tool call deterministically).
**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** the fix is the missing SCOPE: the `tool` frame additively carries the grep's optional `path` (the re-searched document's combined `source/path`), and the UI renders it — `🔎 Searching for <pattern> in <source/path>` — so a whole-KB search and a re-search of one document are visibly different (the "repeating itself" confusion is gone). Bare greps render byte-identical to pre-phase. The scope also rides the status label ("… is searching for X in Y") and persists additively on the saved turn's tool record (`bor.chat.v1` JSONB — old records restore byte-identically, no migration).
## Objective
A grep that re-searches a specific document shows THAT document in the chat's tool line and status label, so repeated patterns with different scopes read as distinct searches; every bare-grep rendering (live, restored, persisted) is byte-identical to pre-phase.
## Dependencies
- `145_git_source_links` (todo) — pipeline order only; no shared code (this phase touches the tool-frame wire + the chat tool-line renderer; 145 touches read results + the doc viewer meta row).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The wire (task 01):**`ChatToolEvent` gains `path: str | None = None` — ADDITIVE-OMISSION: a `model_serializer(mode="wrap")` drops the key when None (the `DocContent``image_url` precedent), so every frame that is not a scoped grep serializes byte-identical to pre-phase. The frame builder sets it ONLY for `piece.name == "grep"` when `piece.arguments.get("path")` is a string (a non-string `path` is a model error the backend already refuses — the guard just yields None → omitted). Legacy tool names never appear on the live wire (the server only emits current names), so no legacy handling.
- **The UI (task 02):**`appendToolLine` gains a fifth `scope` parameter (default null). The grep branch appends, ONLY when `scope` is a non-empty string, a sibling `<span>` AFTER the pattern `<code>`: text node ` in ` + a second `<code>` (the scope is data, never markup; NO new emoji — the frontend emoji guard strips precisely the existing template literals, which stay untouched — the phase-139 read-range-suffix span-append precedent). The live handler reads `ev.path` (string guard), persists it on the `toolRecord` ONLY when non-null (additive JSONB key — a bare-grep record keeps the exact pre-phase shape), and extends the grep status label with ` in ${scope}`. The restore path reads the saved `t.path` through the same helper — a record saved before this phase (no key) renders byte-identically.
- **The E2E (task 02):**`mock_llm.py` gains ONE scripted question branch: a scoped `grep` (`pattern` + `path`) followed by the terminal answer — the house scripted-turn idiom; the bare-grep scripted turns from phases 70/72 stay as the byte-identity pins.
## Tasks
1. `01_tool_frame_scope.md` — the additive `path` field on `ChatToolEvent` + the frame builder + the serialization matrix.
2. `02_ui_scope_render_e2e.md` — the tool line + status label + persisted record + restore path + the dedicated E2E.
## Testing & Quality
- Unit: the frame-serialization matrix (scoped grep / bare grep / read / ls / null-argument — non-scoped frames byte-identical pins).
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] a scoped grep streams `{"type": "tool", "name": "grep", "argument": <pattern>, "path": <source/path>}`; a bare grep's frame is byte-identical to pre-phase (`path` key ABSENT, not null); read/ls frames unchanged
- [ ] the chat tool line for a scoped grep reads `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, no new emoji); the bare-grep line is byte-identical (the phase-70/87/117 pins pass unchanged)
- [ ] the status label says "… is searching for <pattern> in <source/path>" for a scoped grep only
- [ ] a saved scoped-grep turn persists `path` on the tool record and re-renders the scope on restore; pre-phase saved records restore byte-identically
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the read/ls tool lines, the disclosure count, the saved-turn JSON shape for non-scoped records, the done frame)
# Task 01 — the `tool` frame carries the grep's scope: additive `path` on `ChatToolEvent`
**Phase:** `146_search_tool_scope` · **Source:**`TODO.md:3` — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
**Story:** n/a
## Objective
The SSE `tool` frame for a SCOPED grep (a re-search of one document — `grep(pattern, path)`) carries the document's combined `source/path` identity as an additive `path` field, so the UI can show WHERE the search runs. Every frame that is not a scoped grep serializes byte-identical to pre-phase.
## Work
1. `app/schemas.py` — `ChatToolEvent` (L182): add `path: str | None = None` with a `@model_serializer(mode="wrap")` that pops `path` when it is None (the `DocContent._serialize``image_url`-omission precedent — the key is ABSENT from the wire, never `null`). Update the class docstring: the A15 extension line gains the phase-146 note (a scoped grep's optional `path` — the re-searched document's combined `source/path` — is the ONLY case the field is present).
2. `app/api/chat.py` — the `ToolCallPiece` branch (~L915–947, where `argument` is extracted): compute, for `piece.name == "grep"` ONLY, `scope = piece.arguments.get("path")` and coerce with the same string guard as `argument` (`scope if isinstance(scope, str) else None` — a non-string `path` is a model error the agent already refuses; the guard just yields None → omitted). Pass `path=scope` to the `ChatToolEvent(...)` constructor. No other event type, tool name, or field is touched — read/ls frames and every non-scoped grep keep the exact pre-phase wire bytes (the wrap serializer does the omission).
3. `tests/unit/` — the `ChatToolEvent` serialization home (the existing SSE-frame unit file — `tests/unit/test_chat*.py`; if no frame serializer test exists yet, create one following the house layout): the matrix —
- scoped grep: `name="grep"`, `argument="pattern"`, `path="srcs/doc.md"` → the dumped dict has `type`, `name`, `argument`, AND `path` (exact values);
- bare grep: `path=None` → the dumped dict has NO `path` key (assert `"path" not in data` — not `null`);
- `read` (with and without offset/limit) and `ls` (with and without argument) → NO `path` key, byte-identical to pre-phase (assert the full dumped dict equality against the literal pre-phase shape);
- grep with a NON-string `path` (the model-error shape, e.g. a list) → the builder yields `path=None` → omitted (this case is pinned at the builder level in the step-4 test).
4. `tests/unit/` — the frame-builder test (the chat-endpoint unit home that already drives `ToolCallPiece` handling, if one exists — otherwise a focused unit test constructing the piece and invoking the same extraction): grep piece with `arguments={"pattern": "p", "path": "s/d.md"}` → event carries both; grep piece with `arguments={"pattern": "p"}` → no scope; grep piece with `arguments={"pattern": "p", "path": 42}` → scope None (omitted); read/ls pieces → scope never read from their arguments.
## Testing & Quality
- Unit: the two matrices above; every pre-existing frame pin passes UNCHANGED (byte-identity of non-scoped frames is the point).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] a scoped grep's `tool` frame carries `path` = the model's `path` string; a bare grep's frame has no `path` key at all
- [ ] read/ls/null-argument frames dump to the exact pre-phase dict (equality pins)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no other SSE event type or agent-tool surface is touched
# Task 02 — the tool line + status label show the search scope, live and restored; the dedicated E2E
**Phase:** `146_search_tool_scope` · **Source:**`TODO.md:3` — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
**Story:** n/a
## Objective
A scoped grep renders `🔎 Searching for <pattern> in <source/path>` in the tool line and "… is searching for <pattern> in <source/path>" in the status label — live, persisted on the saved turn (additively), and re-rendered on restore. Every bare-grep rendering is byte-identical to pre-phase.
## Work
1. `frontend/assets/app.js` — `appendToolLine` (~L1122): add a fifth parameter `scope = null`. In the grep branch (the `(name === "grep" || name === "search_documents") && argument` arm), AFTER the pattern `<code>` child, append ONLY when `typeof scope === "string" && scope` — the phase-139 read-range-suffix pattern: a `<span>` (class `grep-scope`) containing a text node ` in ` and a second `<code>` whose `textContent = scope` (the scope is data, never markup). The existing `🔎 Searching for ` template literal and the pattern `<code>` are untouched (the frontend emoji guard strips precisely those glyphs in this file — NO new emoji is introduced). A null/empty scope → no span → the line is byte-identical to pre-phase.
2. `frontend/assets/app.js` — the live tool-frame handler (~L3178): read `const framePath = typeof ev.path === "string" && ev.path ? ev.path : null;` (the `ev.offset`/`ev.limit` guard style); add it to the `toolRecord` ONLY when non-null (`if (framePath !== null) toolRecord.path = framePath;` — the additive `bor.chat.v1` JSONB convention: a bare-grep record keeps the exact pre-phase `{name, argument}` shape, old records restore byte-identically, no migration); pass `framePath` as `appendToolLine`'s fifth argument; extend the grep status label — `(name === "grep" || name === "search_documents") && argument` → `` `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}` `` (bare grep → byte-identical label).
3. `frontend/assets/app.js` — the restore path (~L2064–2086): inside the `m.tools` loop, read the saved scope with the same guard (`const tPath = typeof t.path === "string" && t.path ? t.path : null;`) and pass it to `appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)` — a record saved before this phase (no `path` key) renders byte-identically (the phase-95 truncation-note precedent: it keys on the `<code>` argument, not the scope).
4. `tests/e2e/mock_llm.py` — one new scripted-question branch (the house scripted-turn idiom, modeled on the phase-70/72 `grep` branches): a sentinel question → the model streams ONLY a `tool_calls` delta calling `grep` with `{"pattern": <sentinel>, "path": "<fixture source>/<fixture doc>"}` (id `call_0`) → the follow-up round (tool result present) streams the terminal answer. The fixture source/doc pair must exist in the session app's indexed KB (the same fixture the `grep` scripted branches already use — reuse its source/path so the agent's real grep finds matches; the exact path string the UI renders is the model's, so use the fixture's real combined identity). The EXISTING bare-grep scripted turns are untouched (they are the byte-identity pins).
5. `tests/e2e/test_search_tool_scope.py` — the dedicated E2E (one Playwright file, run in isolation, mock-only — the house module-app pattern if the scripted mock lives in the session app, else the session app directly):
- **scoped line:** ask the scripted question → after the tool frame, the `.tool-calls` list holds one `.tool-call` whose text is `🔎 Searching for <pattern> in <source/path>` — assert the TWO `<code>` spans (pattern, then scope) and the ` in ` text node between them (textContent structure, not a regex over the whole line — the pin style of the phase-139 range-suffix assertions);
- **status label:** while the turn is live (before the delta frames), the typing indicator/status carries `… is searching for <pattern> in <source/path>`;
- **bare-grep byte-identity:** an existing bare-grep scripted question renders exactly the pre-phase line `🔎 Searching for <pattern>` (ONE `<code>` span, no `.grep-scope` span) and the pre-phase status label;
- **restore:** save the scoped turn (the save affordance), reload the chat, the restored turn's tool line re-renders the scope through the same structure (folded disclosure — the phase-117 shape is unchanged).
## Testing & Quality
- E2E: the assertions above; every pre-existing tool-line pin (phase-70 labels, phase-87 clock, phase-117 disclosure, phase-139 range suffix) passes UNCHANGED — the byte-identity of bare greps is the point.
- Coverage: n/a (frontend task — the E2E is the gate; `app/` coverage stays >90% from task 01).
## Completion Criteria
- [ ] a scoped grep's tool line shows the scope in a second `<code>` span after the pattern; the status label carries ` in <scope>`; both live and on restore
- [ ] a bare grep renders byte-identical to pre-phase in every surface (live line, status, saved record shape, restored line)
- [ ] the saved scoped record carries `path` additively; pre-phase saved records (no key) restore byte-identically
- [ ] `uv run pytest` green (all pre-existing suites); `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no new emoji anywhere in `app.js` (the frontend emoji guard stays green)
# Phase 147 — sync only what changed: the source file's byte hash is checked BEFORE extraction and vision
**Source:** `TODO.md` L4 — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/importer.py` — `import_sources` (L560: the per-source walk; the `progress`/`on_chunk_progress` hooks fire per file before indexing — unchanged by this phase), `_index_file` (L879: the single upsert entry — dispatches image → `_index_image_file` (L2642), `.pdf` → `_index_pdf_file` (L1181), text fallback), and the three per-branch unchanged blocks that today run only AFTER the expensive work: text (L~928: digest over the read text — cheap, the read itself is the cost), image (L~2660: digest over `read_bytes()` — the vision description runs only on a digest MISS, already fine), PDF (L~1303: digest over the FINAL constructed content — reached only after `extract_pdf_text` (L162 of `app/rag/pdf.py`) ALWAYS runs and, for garbage layers, `_fallback_page_screenshots` (every page rendered + vision-described) or, for sparse layers with `pdf_pictorial` on, `_pictorial_pdf_pass` (every candidate page rendered + vision-described) — so an UNCHANGED pictorial PDF re-extracts and re-burns vision on EVERY sync; a non-deterministic vision model additionally flips the final-content digest and forces a full re-chunk/re-embed/re-summary each run — the owner's "synced every time even if they haven't changed"). `Document.content_hash` (app/models.py L112) is the FINAL-CONTENT digest (exposed via `app/rag/retriever.py` L203/426/555/611 — the dedupe/caching identity) and stays untouched; the new `file_hash` is the RAW SOURCE-FILE-BYTES digest, compared first, used for change detection only. `alembic/versions/` — the latest migration is `0024_git_source_branch.py`. The unchanged-block semantics that must survive the fast path (all three branches, identical shape): `summary.unchanged += 1` + log; the phase-118 (A2) summary-NULL backfill; the phase-106 (D1) `created_at_manual` early-return; the phase-106 (D4) date refresh (`normalize_doc_date` of the raw_date — mtime fallback — may go OLDER, counts `summary.dates_updated`, commits).
**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** change detection = sha256 of the RAW FILE BYTES (the owner's "hash the document contents"), computed ONCE at the top of `_index_file` — before the image/text/PDF dispatch — and compared against a new `documents.file_hash` column (migration 0025, nullable). A match takes a shared unchanged fast path: NO file read (beyond the streamed hash), NO PDF extraction, NO vision call, NO embed, NO summary call — only the preserved bookkeeping (summary-NULL backfill from the STORED `doc.content`, manual-date early-return, date refresh). Pre-phase rows (`file_hash` NULL) fall through to the branches' existing logic exactly as today and get their hash STAMPED on that first post-upgrade sync (one full pass, then fast forever). `content_hash` and everything built on it (retriever exposure, chunk identity) are untouched.
## Objective
An unchanged source file costs one streamed sha256 and a row lookup per sync — zero extraction, zero vision, zero embedding, zero summary LLM — across all three document classes (text, image, pictorial PDF), while every preserved unchanged-path behavior (summary backfill, manual-date lock, date refresh, counts) is byte-for-byte maintained.
## Dependencies
- `146_search_tool_scope` (todo) — pipeline order only; no shared code (this phase touches the importer + one migration; 146 touches the tool-frame wire + the chat renderer).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The column (task 01):**`alembic/versions/0025_documents_file_hash.py` — `documents.file_hash``String(64)`, **nullable, no index** (it is compared in Python after the unique `(source, path)` lookup — unlike `content_hash`, it is never a query surface and never exposed to the retriever); downgrade drops it. `Document.file_hash: Mapped[str | None]` + docstring (the raw source-file-bytes sha256 — the sync change signal; DISTINCT from `content_hash` — the final indexed content's digest, which for a pictorial PDF includes vision text and is the retriever's identity).
- **The fast path (task 02):**
- `_file_hash(path: Path) -> str` — sha256 over the raw bytes, streamed in 1 MiB chunks (a multi-hundred-MB PDF never loads fully; the `hashlib` incremental pattern).
- `_index_file` top (before the image/PDF/text dispatch): `file_digest = _file_hash(full_path)`; the row lookup (the `(source, path)` scalar select the branches already do — moved up); `if doc is not None and doc.file_hash == file_digest:` → `_unchanged_doc(...)` → **return**. The `raw_date` mtime fallback (the D2 convention: read before the unchanged early-return — the branches already do this; the fast path needs it for the date refresh) is computed in `_index_file` before the check when `raw_date is None`.
- `_unchanged_doc(session, doc, *, source, rel, llm, summary, raw_date)` — the ONE shared implementation of the three near-identical unchanged blocks: `summary.unchanged += 1` + the `import: unchanged` log line (unchanged wording); the phase-118 (A2) backfill when `doc.summary is None` — `content=doc.content` (EXACTLY what the bytes produced — for text the stored content IS the file text; for an image the stored description; for a PDF the stored final content — so the backfill never re-reads/re-extracts/re-describes); the D1 `created_at_manual` early-return; the D4 date refresh (`target = normalize_doc_date(raw_date)`; on change: commit + `summary.dates_updated` + the `import: date-refreshed` log line — unchanged wording). The three branches' existing unchanged blocks are REPLACED by the shared helper (the per-branch callsite passes the same arguments; behavior is identical, code is one site).
- **Hash stamping:** every branch writes `doc.file_hash = file_digest` on create AND update (all three — text, image, PDF — `file_digest` passed down from `_index_file`). The fast path makes the stamp redundant for the common case, but a bytes-changed/content-same edge (e.g. a PDF metadata edit with identical extracted text) lands in a branch whose content digest still matches — the branch's unchanged flow (now the shared helper) ALSO stamps `doc.file_hash = file_digest` and commits when it is dirty (the date-refresh commit may already have run — stamp BEFORE that block's final decision: set the attribute, and commit once at the end of the helper if dirty) so the NEXT sync takes the fast path. Pre-phase rows (`file_hash` NULL) take exactly this path on the first post-upgrade sync (full pipeline or the shared helper — whichever the branch's content digest decides) and are stamped either way.
- The `progress`/`on_chunk_progress` hooks are untouched (the `progress` call fires per file before `_index_file` as today — an unchanged file was already "the current file" pre-phase; the fast path just makes it cheap). `summary.files` counts the walked file either way. Pruning, `limit`, the per-source post-index passes (`_enrich_pending_images`, `_backfill_image_contexts` — row-driven) are untouched.
- **The E2E (task 03):** the `file://` fixture-repo module-app idiom (the `test_sync_button.py` recipe — real clone, mock LLM): run 1 (N added) → capture every document row's `indexed_at`/`content_hash` → run 2 → the settle line reads `0 added · 0 updated · N unchanged` and every row's `indexed_at`/`content_hash` is unchanged (the re-index would bump `indexed_at`) → commit a new file to the fixture repo → run 3 → only the new file is `added`.
## Tasks
1. `01_file_hash_column.md` — migration 0025 + the model field.
2. `02_unchanged_fast_path.md` — the streamed hash, the top-of-`_index_file` check, the shared unchanged helper, the stamping, the branch rewiring, the unit matrix.
3. `03_e2e_unchanged_second_run.md` — the dedicated E2E: the idempotent second sync is provably free of re-indexing.
## Testing & Quality
- Unit: the fast-path matrix (text / image / garbage-PDF / pictorial-PDF unchanged → zero LLM calls; changed → full pipeline + stamp; NULL hash → pre-phase behavior + stamp; D1/D4 pins on the fast path; the streamed hash on a >1 MiB file).
- Integration: the migration upgrade/downgrade round-trip (the house migration-test idiom).
- E2E: one dedicated Playwright file, run in isolation, mock-only (real git clone).
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] `documents.file_hash` exists (nullable String(64), no index) via migration 0025; the upgrade/downgrade round-trips on the test DB
- [ ] an unchanged text file, image, garbage-layer PDF, and pictorial PDF each cost ZERO llm calls (embed + chat) and zero PDF extraction/vision on the second sync — `summary.unchanged` counts them; `indexed_at`/`content_hash` untouched
- [ ] a changed file (any byte) re-indexes fully and stamps the new hash; a bytes-changed/content-same file stamps the hash without a content change
- [ ] the preserved unchanged semantics are pinned: summary-NULL backfill, `created_at_manual` early-return, the date refresh (may go older, counts `dates_updated`, commits), the log-line wordings
- [ ] `content_hash` and the retriever's exposure of it are byte-identical to pre-phase
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (prune semantics, `limit`/`progress` contracts, the per-source post-index passes, the sync status/result wire)
# Task 01 — migration 0025: `documents.file_hash` (the raw source-file-bytes digest)
**Phase:** `147_sync_changed_only` · **Source:**`TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
**Story:** n/a
## Objective
The `documents` table gains `file_hash` — the sha256 of the RAW SOURCE FILE's bytes — the column the task-02 fast path compares before any extraction or vision work. Existing rows stay NULL (= "unknown" — one full pipeline pass stamps them on the next sync; no backfill is possible in a migration — the migration cannot see the source files on disk).
- `upgrade`: `op.add_column("documents", sa.Column("file_hash", sa.String(length=64), nullable=True))` — **no index** (the fast path compares it in Python after the unique `(source, path)` lookup; unlike `content_hash` — indexed because it is a query/exposure surface — `file_hash` is internal bookkeeping) and no server default (NULL is the meaningful pre-phase state).
2. `app/models.py` — `Document` (L102): add, directly below `content_hash` (L112), `file_hash: Mapped[str | None] = mapped_column(String(64), default=None)` with a docstring in the house style: the sha256 of the RAW SOURCE FILE's bytes at index time (phase 147) — the sync's change signal, compared at the top of the importer's `_index_file` BEFORE the image/text/PDF dispatch (an unchanged file costs one streamed hash + one lookup — no extraction, no vision, no embed). NULL for pre-phase rows and until the first post-upgrade sync stamps it. DISTINCT from `content_hash` — the FINAL indexed content's digest (a pictorial PDF's includes vision text; `content_hash` is exposed via `app/rag/retriever.py` and is the dedupe/caching identity — untouched).
3. Tests (the house migration-test idiom — the integration test that upgrades the test DB to head, or the models unit home): the column exists, is nullable, is NOT indexed (assert against the live table metadata after `upgrade head`); the `Document` model exposes `file_hash` (default None); a row created with no `file_hash` round-trips NULL (the pre-phase shape).
## Testing & Quality
- Integration: migration upgrade (to head) + downgrade + re-upgrade round-trip on the test DB (the existing migration-test pattern — extend it, don't fork it).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] `0025_documents_file_hash` chains from `0024` and upgrades/downgrades cleanly on the test DB
- [ ] `file_hash` is nullable String(64) with no index; `content_hash` (indexed, exposed) is untouched
- [ ] `Document.file_hash` defaults to None and carries the house-style docstring distinguishing it from `content_hash`
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
# Task 02 — the unchanged fast path: hash first, then skip extraction, vision, and embedding
**Phase:** `147_sync_changed_only` · **Source:**`TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
**Story:** n/a
## Objective
An unchanged file (raw bytes identical to the last index) is detected at the TOP of `_index_file` — before the image/text/PDF dispatch — and takes one shared unchanged path with ZERO llm calls, ZERO PDF extraction, and ZERO vision work, while the three branches' existing unchanged semantics (summary-NULL backfill, manual-date early-return, date refresh) survive verbatim, and every newly indexed/updated document stamps its file hash.
## Work
1. `app/rag/importer.py` — new helper `_file_hash(path: Path) -> str` (module level, near the other file helpers): sha256 of the raw bytes, streamed in 1 MiB chunks (`open(path, "rb")` + `hashlib.sha256()` incremental `update` in a loop — a large PDF never loads fully into memory).
2. `app/rag/importer.py` — new helper `async def _unchanged_doc(session, doc, *, source, rel, llm, summary, raw_date, file_digest)` — the ONE shared implementation of the three near-identical per-branch unchanged blocks (text ~L928, image ~L2660, PDF ~L1303), in this exact order with UNCHANGED log wordings and counts:
2. **stamp**`doc.file_hash = file_digest` (a pre-phase NULL row, or a bytes-changed/content-same row, learns its hash here — see step 5);
3. the phase-118 (A2) backfill when `doc.summary is None` — `await _store_summary(session, doc=doc, source=source, rel=rel, content=doc.content, llm=llm, summary=summary, backfill=True)` (EXACTLY what the file's bytes produced is already stored in `doc.content` for all three classes — text: the file text; image: the vision description; PDF: the final constructed content — so the backfill never re-reads the file, never re-extracts, never re-describes; the text branch's pre-phase `content` variable was the freshly-read file text — byte-identical to `doc.content` by the digest-equality invariant);
4. the D1 early-return: `if doc.created_at_manual: return` (no write at all — the manual correction survives);
5. the D4 date refresh: `target = normalize_doc_date(raw_date)`; `if target != doc.created_at:` set + `session.commit()` + `summary.dates_updated += 1` + the `import: date-refreshed` log line (unchanged wording) — the date may go OLDER, no monotonic guard;
6. **final commit for the stamp**: if the row is still dirty after the date block (the stamp set `file_hash` and the date did not change) → `session.commit()` (quiet bookkeeping — no count, no log: the `unchanged` line already fired; a date-change run already committed in step 5).
3. `app/rag/importer.py` — `_index_file` (L879): BEFORE the image/PDF/text dispatch:
- `file_digest = _file_hash(full_path)` (every class hashes the raw bytes — images, text, PDFs alike; this is the single change signal, per the owner's "hash the document contents");
- the row lookup moves up here: `doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))` (the branches keep their OWN lookups only if the fast path was not taken — see step 4);
- `if raw_date is None: raw_date = file_mtime_datetime(full_path)` (the D2 fallback, read before the unchanged early-return — the branches' existing convention, now once at the top);
- `if doc is not None and doc.file_hash == file_digest: await _unchanged_doc(session, doc, source=source, rel=rel, llm=llm, summary=summary, raw_date=raw_date, file_digest=file_digest); return` — **zero** further work: no `read_text`, no `read_bytes`, no `extract_pdf_text`, no fallback/pictorial vision, no `_replace_content_chunks`, no `_store_summary` (unless the backfill fires).
4. `app/rag/importer.py` — the three branches, rewired minimally:
- each branch receives `file_digest` (and the already-computed `raw_date`) from `_index_file`; their internal unchanged blocks are REPLACED by the `_unchanged_doc` call (same arguments — one code site for the semantics);
- their create/update writes gain `doc.file_hash = file_digest` (all three — the `Document(...)` constructor for new rows, the `doc.file_hash = ...` line for updates, next to `content_hash`);
- their existing row lookups and content-digest computations STAY (reachable only when `file_hash` is NULL — pre-phase row — or the bytes changed: the content digest then decides changed vs. the shared helper, and the helper stamps the new hash);
- the image branch's `data = full_path.read_bytes()` and the text branch's `read_text` happen only AFTER the fast-path return point (unchanged — the dispatch is what gates them), so the fast path genuinely pays nothing beyond the streamed hash + lookup.
5. `app/rag/importer.py` — docstrings: `_index_file`'s gains the phase-147 paragraph (the fast path: what it skips, the NULL-hash fall-through, the stamping rule); the three branches' "unchanged path" docstring lines are updated to point at the shared helper; the module docstring's A9-format paragraph is untouched.
6. `tests/unit/` — the importer unit home (the existing `tests/unit/test_importer*.py` matrix — extend it): with a call-counting fake llm (embed + chat both counted):
- **text unchanged:** index a file, capture the llm call count; re-run `import_sources` → `summary.unchanged == 1`, ZERO new llm calls, `summary.files == 1`, the doc's `indexed_at`/`content_hash` unchanged, `file_hash` stamped after run 1;
- **garbage-layer PDF unchanged:** a no-text-layer PDF (the house fixture recipe) indexed with the fake vision; re-run → `summary.unchanged == 1`, ZERO new llm calls (the fallback vision did NOT re-fire — the owner's core complaint), the content row byte-identical;
- **pictorial PDF unchanged:** a sparse-page PDF (`pdf_pictorial` on, fake vision) indexed; re-run → ZERO new llm calls (the pictorial pass did NOT re-fire);
- **image unchanged:** re-run → ZERO new llm calls;
- **changed file:** flip one byte → re-run → `summary.updated == 1`, the llm calls fire again, `file_hash` re-stamped, `indexed_at` bumped;
- **pre-phase row (NULL `file_hash`):** a row with content but NULL `file_hash` (seeded directly) + an unchanged file → the branch's content-digest path runs (today's behavior — the llm may or may not be called per the digest match) AND the hash ends stamped (both the changed and the shared-helper outcomes);
- **D1/D4 on the fast path:** an unchanged file with `created_at_manual=True` → no write at all (the date stays the manual value, no commit beyond the stamp — assert the row's date unchanged and the flag intact); an unchanged file whose mtime-derived date differs → the date refreshes (may go older), `dates_updated == 1`;
- **summary-NULL backfill on the fast path:** an unchanged doc with `summary=None` → the backfill runs from `doc.content` (ONE `is_summary` chunk, no content re-embed — the phase-118 pin, now via the shared helper);
- **streamed hash:** a >1 MiB temp file hashes identically to `hashlib.sha256(bytes).hexdigest()` (the chunking is transparent).
7. The sync status/result wire (`app/api/sync.py` + `ImportSummary` fields) is UNTOUCHED — the fast path feeds the same `unchanged`/`dates_updated`/`added`/`updated` counts the UI already renders.
## Testing & Quality
- Unit: the matrix above — the ZERO-llm-call assertions on the second sync are the load-bearing pins (the owner's "really long sync times for PDFs that are mostly pictoral" is fixed when a pictorial PDF's second sync burns no vision).
- Coverage: **>90%** on this task's new/modified code (`app/`); the pre-phase importer pins (progress pre-walk, `limit`, prune, the post-index passes) pass UNCHANGED.
## Completion Criteria
- [ ] the fast path fires on a `file_hash` match for all three classes and returns before any branch code (zero llm/extraction/vision — pinned by call counters)
- [ ] the shared `_unchanged_doc` preserves the unchanged semantics verbatim: counts, log wordings, the backfill (from `doc.content`), the D1 early-return, the D4 refresh (may go older, commits, counts)
- [ ] every create/update in all three branches stamps `file_hash`; the shared helper stamps NULL/changed rows too (the next sync is always fast)
- [ ] pre-phase rows behave exactly as today on the first post-upgrade sync and are stamped
- [ ] `content_hash`, the retriever, the progress/chunk hooks, pruning, and the per-source post-index passes are byte-identical to pre-phase
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
# Task 03 — the dedicated E2E: the idempotent second sync provably re-indexes nothing
**Phase:** `147_sync_changed_only` · **Source:**`TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
**Story:** n/a
## Objective
End-to-end proof, through the real UI + the real sync path (a real `git clone` of a local `file://` fixture repo, a real import against the mock LLM): the second sync of an unchanged source reports everything unchanged and touches no document row; a source change syncs ONLY the changed part.
## Work
1. `tests/e2e/test_sync_changed_only.py` — the dedicated suite (one Playwright file, run in isolation; the `test_sync_button.py` module-app idiom — the module app boots with `BOR_GIT_SOURCES=file://<fixture repo>` + its own `BOR_SOURCES_DIR` on its own port (env-overridable, the house port-constant pattern), the real `git` clone prerequisite, the generous `SYNC_TIMEOUT_MS`):
- **Fixture:** the house fixture-repo builder (the `test_sync_button.py` recipe — a local git repo with a few importable docs, e.g. the `notes/sync-fixture.md` sentinel doc + one or two more, committed) — NO PDF needed (the unit matrix covers the vision-skip proof; this E2E pins the WIRE + the UI settle line + the row-level proof, and stays fast/deterministic).
- **Test 1 — `test_second_sync_unchanged`:** admin login (the house `login` helper); sync run 1 → wait for the settle line (the `Synced HH:MM` pattern) → capture EVERY document row's `indexed_at` + `content_hash` + `file_hash` (via `SessionLocal` + `select(Document)`, the `test_sync_button.py` DB-assertion idiom) → assert `file_hash` is non-NULL on every row (the first sync stamped them) → sync run 2 → the settle line matches the house count shape with `0 added`, `0 updated`, and the unchanged count == the run-1 document count (`fmtSyncResult` renders `N unchanged` — assert the visible `#sync-result` text) → re-query: every row's `indexed_at`/`content_hash` UNCHANGED (a re-index would bump `indexed_at` to now — the load-bearing assertion), `file_hash` unchanged.
- **Test 2 — `test_change_syncs_only_the_change`:** from the settled state, append a NEW doc file to the fixture repo working tree + `git commit` (the house `_git` helper) → sync run 3 → the settle line shows `1 added` (and the unchanged count == the run-1 count) → DB: the new row exists with `file_hash` non-NULL; the pre-existing rows' `indexed_at`/`content_hash` unchanged.
- **Test 3 — `test_changed_file_reindexes`:** modify the fixture sentinel doc's content (append a line) + `git commit` → sync run 4 → the settle line shows `1 updated` → DB: exactly that row's `indexed_at`/`content_hash`/`file_hash` changed; the others untouched.
2. Every test leaves the app stopped (the module-app fixture teardown — the house pattern); the suite is hermetic (its own `BOR_SOURCES_DIR` under a tmp dir, its own port) so `--no-cov` isolation holds.
## Testing & Quality
- E2E: the three tests above, mock-only (the module app's LLM is the mock), real git + real import; each test asserts on the VISIBLE settle line (the owner's surface) AND the DB rows (the proof).
- [ ] run 2 of an unchanged source: the settle line reads `0 added · 0 updated · N unchanged` (N = the KB size) and no document row's `indexed_at`/`content_hash`/`file_hash` moved
- [ ] a new file syncs as `1 added` without touching the other rows; a modified file syncs as `1 updated` and only its own row changes
- [ ] every synced row carries a non-NULL `file_hash` after the first run
- [ ] `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` green in isolation (DB up, git on PATH — the documented prerequisite); `uv run pytest` still green (the pre-existing sync E2Es — the idempotent-second-run pins of `test_sync_button.py` etc. — pass unchanged)
# Phase 148 — pictorial PDFs show their PAGES in the sources, not their page descriptions
**Source:** `TODO.md` L5 — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me. I still want to be able to ask questions, so the summary and embedding is helpful, but not when shown to the user."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** the owner's named fixture is the **pictorial class** — `test_sources/ikea_instructions.tar` → `besta-frame-white__AA-1272068-11-100.pdf`: 24 pages, USABLE text layer (`is_usable_pdf_text` True) but 20 of 24 pages carry <100 stripped chars (3 dense text pages, 3k–7.3k chars) — so the phase-133 screenshot fallback does NOT fire for it; the phase-140 pictorial pass (`_pictorial_pdf_pass`, gated on `pdf_pictorial` — default ON — + `pictorial_pages(texts, min_chars=100)`) describes its 20 sparse pages and appends `[Page N illustration: …]` blocks. Today the document viewer (`frontend/assets/document.js` — `renderDocument` L172, the shared core of the doc modal AND `/document.html`) shows such a PDF as its raw text content in `<pre class="doc-raw">` + the labeled Summary panel — the descriptions the owner says are "not … helpful" to a human. The rendering machinery already exists: `app/rag/pdf.py``render_pdf_pages(pdf_path, dpi=150, pages=None)` (L311 — every page to PNG, `b""` placeholder on a per-page failure, `[]` for an unopenable file; the phase-133/140 callers). The persistent-copy convention to follow: phase 122's image copies — `settings.image_dir` (app/config.py L397, raw string, `expanduser`'d by the importer), `<doc-id>.<ext>` naming, written only after success, stale copy deleted before replacement (importer ~L2790), deleted on prune (importer `_prune` ~L2924), because "the source file is disposable: uploads are replaced on every upload, git checkouts are re-cloned, local dirs are user-edited". The viewer payload: `DocContent` (app/schemas.py — `image_url`'s additive-omission serializer is the wire precedent; the content endpoint is `GET /api/documents/content`, app/api/docs.py L165 — no doc id in the payload today, so page URLs must ride as full routes). The bytes route precedent: `GET /api/documents/{doc_id}/image` (app/api/docs.py L216 — PUBLIC, the unguessable server-minted `uuid4` IS the credential — phase-124 A4, visible on the shared page; 404 `document not found` for every non-servable case; no path parameter reaches the filesystem — the path comes from the ROW).
**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):**
- **Eligibility** — a PDF gets rendered pages iff the sync ALREADY decided its text is not the useful view: the phase-133 fallback fired (unusable text layer — the screenshot class) OR the phase-140 pictorial pass ran and described ≥1 page (the sparse-page class — the IKEA fixture). Text-heavy PDFs (usable layer, zero candidates) keep the text view; `BOR_PDF_PICTORIAL=0` keeps its kill-switch semantics (no pictorial descriptions → no page strip for the sparse class — the toggle off means the pre-phase-148 viewer for those PDFs).
- **Storage** — rendered at INDEX time (phase 147's fast path makes this cost one-time-per-real-change), persisted as PNGs in a NEW `settings.pdf_page_dir` (default `~/bor-sources/pages`, `BOR_PDF_PAGE_DIR` — the `image_dir` convention) named `<doc-id>-p{N}.png` (N 1-based, every page of the PDF); `documents.page_count` (migration 0026, nullable Int) = the PDF's page count (a per-page render failure leaves a gap — the route 404s that N, the viewer shows its unavailable note); stale copies deleted on re-index, pruned, and on a document that stops qualifying.
- **Display** — the shared `renderDocument` core: for a doc with `page_urls`, the page strip (lazy `<img>`s, "Page N" captions) REPLACES the text content AND the Summary panel (the owner: the description "is helpful … but not when shown to the user" — it stays embedded + summarized for Q&A, just not displayed); the meta badge row is untouched; both surfaces (modal + full page) get it through the one core.
## Objective
A pictorial-class PDF (the IKEA assembly-instruction class) opens in the sources with its actual pages — every page as a rendered image — while text-heavy PDFs, images, and text documents render byte-identically to pre-phase, and the Q&A machinery (embeddings, summaries) is untouched.
## Dependencies
- `147_sync_changed_only` (todo) — REAL coupling: the page render happens in the PDF branch's new/changed path, which phase 147's fast path makes one-time-per-real-file-change (without it, an unchanged pictorial PDF would re-render its pages every sync — the same cost the owner complained about).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Index (task 01):** in `_index_pdf_file`, AFTER the final content is decided (fallback/pictorial passes done) and the doc is flushed (it has an id): when `new_or_changed` (reaching this code IS new/changed — the fast path returned earlier) AND eligible (the fallback fired, or the pictorial pass returned non-None): `pages = render_pdf_pages(full_path)` (every page, 150 dpi — the existing constant); delete this doc's stale page copies first (the image-copy stale-replacement convention); write each non-empty PNG to `pdf_page_dir` (created on demand) as `<doc-id>-p{N}.png`; `doc.page_count = len(doc)` (the PDF's page count — gaps tolerated). NOT eligible, or zero pages rendered (unopenable file — `render_pdf_pages``[]`): `doc.page_count = None` + delete any stale copies (a doc that stops qualifying loses its strip). Fail-soft: a render/write exception → `page_count = None`, warning, the document indexes exactly as today. In `_prune` (and the PDF parent's embedded-image deletion site), a deleted page-qualifying doc's copies are removed (a shared `_delete_page_copies(doc)` helper — the `_delete_image_copy` convention; `source_removal.py` deletes rows only — the image-copy convention there — and is untouched).
- **Serve (task 02):**`GET /api/documents/{doc_id}/pages/{n}` — public (the image route's trust model verbatim: the unguessable `uuid4` is the credential; a shared chat can cite the page), `n: int` path parameter ≥ 1; 404 `document not found` for: unparseable id, unknown doc, `page_count` NULL, `n` outside `1..page_count`, missing file on disk (defensive — the viewer's onerror note); the path is built from the ROW's id + `n` (no traversal surface — `n` is an int); `image/png` + `Cache-Control: private, max-age=3600` (the image route's header). `DocContent.page_urls: list[str] | None = None` — the content endpoint fills `[f"/api/documents/{doc.id}/pages/{i}" for i in 1..doc.page_count]`; the serializer drops the key when None (the `image_url` pattern — never `null`, absent for text/image docs and non-qualifying PDFs).
- **View (task 03):**`renderDocument` (the shared core — modal + page): `doc.page_urls` a non-empty array → a `.doc-pages` strip: one `<figure class="doc-page">` per URL — `<img loading="lazy" src=url alt="Page N of {doc.title}">` + `<figcaption>Page N</figcaption>`; the markdown/`.doc-raw` content block is NOT rendered for this doc, and the Summary panel is NOT rendered (the owner's contract — the text stays in the DB for Q&A, it is simply not displayed); the meta badge row renders as today; `onerror` on an `<img>` swaps its figure for a "Page N unavailable" note (the phase-122 image-unavailable idiom, `role="status"`); `createElement`-only (the XSS contract — never innerHTML from doc data). CSS: `.doc-pages` in the existing doc content column (the ≤46rem core), figures stacked, `img { max-width: 100% }`, theme vars for border/surface/ink (both themes), `overflow-wrap` not needed (no text flows), contrast AA, `prefers-reduced-motion` irrelevant (no motion).
## Tasks
1. `01_page_render_at_index.md` — `pdf_page_dir` setting + `page_count` migration/model + the PDF-branch render/persist/cleanup + the prune hook + the unit matrix.
2. `02_page_route_and_payload.md` — the public page-bytes route + `DocContent.page_urls` + the endpoint + unit/integration tests.
3. `03_viewer_page_strip.md` — the `renderDocument` strip (content + summary suppressed for qualifying docs) + the CSS.
4. `04_e2e_pdf_pages.md` — the dedicated E2E: a synthetic pictorial PDF shows its pages in the modal + full page; a text-heavy PDF renders byte-identical.
## Testing & Quality
- Unit: the eligibility matrix × {new, changed, unchanged-fast-path, unopenable}; copy naming/stale-cleanup/prune-deletion; the route matrix; the payload omission rule.
- Integration: the content endpoint returns `page_urls` for a seeded page-qualifying doc and omits the key otherwise.
- E2E: one dedicated Playwright file, run in isolation, mock-only (the house PyMuPDF fixture recipe).
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] a pictorial-class PDF (IKEA shape: usable layer, ≥1 sparse page) and a fallback-class PDF (unusable layer) get `<doc-id>-p{N}.png` copies for every page + `page_count` = the page count; a text-heavy PDF gets neither
- [ ] an unchanged qualifying PDF's second sync (phase 147 fast path) re-renders nothing (the copies persist, zero render calls)
- [ ] re-indexing a changed PDF replaces its copies (no stale `p{N}` orphans); a PDF that stops qualifying loses them; pruning deletes them
- [ ] `GET /api/documents/{id}/pages/{n}` serves the PNG (public, uuid4-credential model) and 404s every non-servable case; `DocContent.page_urls` is present for qualifying docs and the key is ABSENT otherwise
- [ ] the modal AND /document.html show the page strip for qualifying docs (lazy imgs, "Page N" captions) with the text content + Summary panel suppressed; every other document renders byte-identical to pre-phase
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the fallback/pictorial content, `content_hash`, embeddings/summaries, the image route, the doc-edit/date/summary admin APIs)
# Task 01 — render qualifying PDF pages at index time: the persistent copies + `page_count`
**Phase:** `148_pdf_pages_in_sources` · **Source:**`TODO.md:5` — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages…"
**Story:** n/a
## Objective
A qualifying PDF (fallback class or pictorial class) has every page rendered to a persistent PNG at index time — one-time per real file change (phase 147's fast path) — with `documents.page_count` recording the page count, stale copies cleaned on re-index/qualifier-loss/prune, and a fail-soft posture: a render failure never breaks the document's indexing.
## Work
1. `app/config.py` — beside `image_dir` (L397): `pdf_page_dir: str = "~/bor-sources/pages"` (`BOR_PDF_PAGE_DIR`) with the house docstring: where rendered PDF page PNGs are copied for serving (phase 148) — raw string, `Path.expanduser()` applied by the importer (the `sources_dir`/`upload_dir`/`image_dir` convention); deliberately separate from `sources_dir`/`upload_dir` (git checkouts re-cloned, uploads replaced, local dirs user-edited — the served copy must outlive the source file, the `image_dir` rationale).
2. `alembic/versions/0026_documents_page_count.py` — `revision = "0026"`, `down_revision = "0025"`: `op.add_column("documents", sa.Column("page_count", sa.Integer(), nullable=True))` (no index — a display fact, never a query surface); downgrade drops it. `app/models.py` — `Document.page_count: Mapped[int | None] = mapped_column(Integer, default=None)` + house docstring: the number of rendered page PNGs persisted in `pdf_page_dir` as `<doc-id>-p{N}.png` (phase 148) — the PDF's page count for a page-qualifying document (the phase-133 fallback class or the phase-140 pictorial class); NULL for every other document (text, images, text-heavy PDFs); set/cleared ONLY by the importer's PDF branch (+ prune cleanup deletes the copies).
3. `app/rag/importer.py` — `_index_pdf_file` (L1181), in the NEW/CHANGED path AFTER `session.flush()` (the doc has its id) and after the final content is decided (the fallback/pictorial passes have run — the eligibility signals are local: `fallback_fired` = the phase-133 gate fired (the `summary.pdfs_screenshotted += 1` site), `pictorial_described` = `_pictorial_pdf_pass` returned non-None):
- `eligible = fallback_fired or pictorial_described`;
- when eligible: `pages = render_pdf_pages(full_path)` (every page — `pages=None`, 150 dpi, the existing helper's contract: `b""` placeholder at a failed page's position, `[]` for an unopenable file); FIRST delete this doc's existing page copies (the stale-replacement convention — a changed PDF's old page set, e.g. a 30-page PDF that became 24, must not leak `p25..p30`); then write each non-empty PNG to `Path(llm.settings.pdf_page_dir).expanduser() / f"{doc.id}-p{N}.png"` (N 1-based, list-index aligned — the helper's "list index i is page i+1" default contract; the directory is `mkdir(parents=True, exist_ok=True)` on demand — the `image_dir` pattern); `doc.page_count = total_pages` where `total_pages` is the PDF's page count (the `fitz` doc length — re-derive via a tiny helper or `len(pages)` when the file opened: `render_pdf_pages` returns one slot per page, so `len(pages)` IS the page count for an openable file; `[]` → the unopenable case);
- when NOT eligible, or `pages == []` (unopenable), or ALL pages failed (zero non-empty PNGs): `doc.page_count = None` + delete any existing copies for this doc (a document that STOPPED qualifying loses its strip — e.g. a new version gained a real text layer);
- **fail-soft:** wrap the render/persist block — any exception → `page_count = None` + `logger.warning` (the house wording style) + the document proceeds to chunk/embed/summary EXACTLY as pre-phase (the render is a display concern — it never gates indexing); a per-page `b""` is NOT an exception (the gap is served as a 404 later, the viewer's note covers it).
- the UNCHANGED path (phase 147's fast path — `_unchanged_doc`) does NOT touch pages or `page_count` (the copies persist; zero render work — assert this).
4. `app/rag/importer.py` — a shared `_delete_page_copies(doc, page_dir) -> None` helper (the `_delete_image_copy` convention, L2626: missing files are a silent no-op, never an error): delete `<doc-id>-p1..p{page_count}.png` (iterate 1..`doc.page_count` — the row knows its own extent; a NULL/zero count is a no-op). Call sites: (a) the stale-replacement in step 3 (pass the doc BEFORE its `page_count` changes), (b) `_prune` (L2870) — beside the existing `_delete_image_copy(doc.image_path)` call (~L2924) for a pruned page-qualifying doc, (c) the embedded-image parent-PDF prune path (the pruned parent's copies too — the same site that deletes the parent's embedded-image copies). `app/rag/source_removal.py` is UNTOUCHED (row-only deletion — the image-copy convention there).
5. `tests/unit/` — the importer unit home:
- **eligibility × lifecycle matrix** (fake llm, tmp `pdf_page_dir`, the house PyMuPDF fixture recipes — the `test_pictorial_pdf.py` builder for the pictorial class, a textless/blank-page PDF for the fallback class, a dense-text PDF for the negative): new index of a qualifying PDF → copies `p1..p{N}` exist (N = page count), `page_count == N`, the PNG bytes are non-empty; re-run unchanged (phase 147 fast path) → ZERO render calls (count via monkeypatched `render_pdf_pages` or call counter), copies + `page_count` untouched; change the file (append a page to the fixture) → re-index → the OLD copy set replaced (a shrunk PDF leaves no stale `p{N}`), `page_count` updated;
- **qualifier loss:** a qualifying PDF's new version has a dense text layer (zero candidates, `pdf_pictorial` on) → `page_count` NULL + copies deleted;
- **unopenable:**`render_pdf_pages` returns `[]` (monkeypatched) → `page_count` NULL, no copies, no exception, the doc still indexed (chunks/summary ran);
- **per-page gap:** one page's render yields `b""` (monkeypatched) → the other copies written, `page_count` = the full count (the gap is a display-level 404, pinned in task 02);
- **render exception:** the writer raises → `page_count` NULL, warning logged (caplog), the doc indexed fully (fail-soft);
- **prune:** a qualifying doc pruned (its file removed from the source) → row gone + copies gone (no orphans in the tmp dir);
- **settings:**`pdf_page_dir` default `~/bor-sources/pages`, env override `BOR_PDF_PAGE_DIR` (the settings test home).
## Testing & Quality
- Unit: the matrix above; the phase-147 fast-path pins pass UNCHANGED (the fast path skips the render block by construction — it returns before the branch); the pre-phase PDF-branch pins (fallback content, pictorial blocks, `content_hash` over the final content, the embedded-image pass) pass UNCHANGED.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] qualifying PDFs (both classes) get `<doc-id>-p{N}.png` for every renderable page + `page_count` = the page count; non-qualifying PDFs, text docs, and images get neither
- [ ] unchanged re-syncs render nothing; changed re-syncs replace the copy set without orphans; qualifier-loss and prune delete the copies
- [ ] a render failure is fail-soft: the document indexes exactly as pre-phase, `page_count` NULL, a warning is the signal
- [ ] `settings.pdf_page_dir` follows the `image_dir` convention (raw string, expanduser'd at use); migration 0026 round-trips
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
# Task 02 — serve the pages: the public route + `DocContent.page_urls`
**Phase:** `148_pdf_pages_in_sources` · **Source:**`TODO.md:5` — "I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me."
**Story:** n/a
## Objective
The viewer payload names every rendered page (`page_urls`, full routes, additive-omitted), and a public bytes route serves one page PNG at a time — the image route's trust model and 404 posture verbatim.
## Work
1. `app/api/docs.py` — new route beside `get_document_image` (L216): `@router.get("/documents/{doc_id}/pages/{n}", response_class=FileResponse)` → `def get_document_page(doc_id: str, n: int, db = Depends(get_db)) -> FileResponse` (the `{n}` int path parameter is parsed by FastAPI — a non-int 422s at the router, mirroring how the image route's unparseable `doc_id` maps to its 404 below):
- the docstring: PUBLIC (the phase-124 A4 trust model verbatim — a shared chat can cite a page figure, so the bytes serve ANONYMOUSLY; the document id is an unguessable server-minted `uuid4` — IT IS the credential, no enumeration value — exactly like the image route and the saved-chat id; the document's TEXT stays user-gated: only the page bytes are public);
- 404 `detail="document not found"` (the router's unknown-document shape) for EVERY non-servable case: unparseable `doc_id` (the `uuid.UUID` try/except → 404, not 422 — the image route's mapping), an unknown doc, `page_count` NULL (a non-qualifying doc — the route knows nothing of its pages), `n < 1` or `n > doc.page_count` (out of range — the int parameter makes `n < 0`/fractional impossible; a gap — a page whose render failed — hits the missing-file 404 below), or the file on disk missing (defensive — the copy was lost; the viewer's onerror note covers it);
- the path is built ONLY from the row's id + `n` (`Path(settings.pdf_page_dir).expanduser() / f"{doc.id}-p{n}.png"` — `settings` via the house import) — no request value reaches a filesystem path (no traversal surface, the image route's property);
- `FileResponse(page_file, media_type="image/png", headers={"Cache-Control": "private, max-age=3600"})` (the PNG is always PNG — `render_pdf_pages`'s `tobytes("png")`; the image route's header, the content-hashed-bytes rationale).
2. `app/schemas.py` — `DocContent`: add `page_urls: list[str] | None = None` + docstring (phase 148 — the rendered-page bytes routes `["/api/documents/<id>/pages/<i>" for i in 1..page_count]` for a page-qualifying PDF; ABSENT from the wire for every other document — `None` → dropped by the serializer, never `null`, the `image_url` omission precedent); extend `_serialize` to pop `page_urls` when None (beside the existing `image_url` pop).
3. `app/api/docs.py` — `get_document_content` (L165, the `GET /documents/content` endpoint): when `doc.page_count` is non-NULL, fill `page_urls = [f"/api/documents/{doc.id}/pages/{i}" for i in range(1, doc.page_count + 1)]` (the `/api` prefix — the `image_url` field carries the same full-route shape; check `image_url`'s exact construction and mirror it byte-for-byte in convention).
4. `tests/unit/` + the docs-endpoint integration home:
- **route matrix** (the TestClient idiom the image-route tests use): a seeded qualifying doc (row with `page_count=2` + real PNG files in a tmp `pdf_page_dir`) → `GET .../pages/1` and `/2` → 200, `content-type: image/png`, the `Cache-Control` header, the body bytes == the stored file's bytes; `n=0`, `n=3` → 404 `document not found`; a text doc row / an image doc row / a PDF row with `page_count=NULL` → 404 on any `n`; a qualifying row whose file was deleted on disk → 404; a non-uuid `doc_id` (`"abc"`) → 404 (not 422);
- **payload:** the content endpoint for a qualifying doc → `page_urls` == the exact route list (1-based, count == `page_count`); a text doc / an image doc / a non-qualifying PDF → the `page_urls` KEY is ABSENT from the JSON (assert `"page_urls" not in data` — the omission pin, the `image_url` pattern);
- **no traversal:** the route is structurally impossible to point at an arbitrary path (the int parameter + row-derived path — assert the code builds the path from `doc.id` + `n` only, pinned by the out-of-range 404s).
## Testing & Quality
- Unit + integration: the matrices above; every pre-existing `DocContent` pin passes UNCHANGED (a non-qualifying doc's payload is byte-identical — the key is absent, not null); the image route's tests are untouched.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] `GET /api/documents/{id}/pages/{n}` serves the stored PNG (public, uuid4-credential, `image/png`, the image route's cache header) and 404s EVERY non-servable case with `document not found`
- [ ] `DocContent.page_urls` is the exact 1-based route list for qualifying docs and the key is ABSENT (never `null`) for every other document
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
# Task 03 — the viewer shows the pages: the strip replaces the text and the summary panel
**Phase:** `148_pdf_pages_in_sources` · **Source:**`TODO.md:5` — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. … I still want to be able to ask questions, so the summary and embedding is helpful, but not when shown to the user."
**Story:** n/a
## Objective
For a document whose payload carries `page_urls`, the shared `renderDocument` core renders the page strip — every page as a lazy image with a "Page N" caption — and suppresses the text content AND the Summary panel (the descriptions stay in the DB for Q&A, just not displayed). Every other document renders byte-identically to pre-phase, on both surfaces (the doc modal and `/document.html`).
## Work
1. `frontend/assets/document.js` — `renderDocument` (L172 — the ONE shared core; the modal and the full page both call it, no per-surface copy):
- the gate: `const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null;` — a non-empty array triggers the strip; `null`/absent/empty → the EXACT pre-phase rendering (assertable byte-identity for every other doc);
- the strip: a `<div class="doc-pages">` container (replacing what would become the content slot) holding one `<figure class="doc-page">` per URL — `<img loading="lazy" src={url}>` with `alt = `Page ${i} of ${doc.title}`` (the WCAG alt contract — the phase-122 image idiom, the title as the fallback context) and a `<figcaption class="doc-page-num">Page ${i}</figcaption>` (i 1-based over `page_urls`);
- **suppression:** when the strip renders, the markdown/`.doc-raw` content block is NOT built AND the labeled Summary panel (`.doc-summary`, phase 36 — including its admin edit affordance wiring, which is moot without the panel) is NOT built — the owner's contract: the page descriptions are "not … shown to the user"; the meta badge row (Created/Indexed, the phase-106 badges) renders exactly as today;
- **unavailable page:** each `<img>` gets an `onerror` handler (the phase-122 "Image unavailable" idiom) that swaps ITS figure's `<img>` for a small "Page N unavailable" note element (`role="status"`, muted styling) — the rest of the strip is untouched (one lost copy never hides the other pages; the defensive 404s of task 02's route are the trigger);
- **XSS contract:**`createElement`/`textContent` only — every document-derived string (title, URLs, the note) lands via properties/`textContent`, never innerHTML (the file's pinned contract — the module docstring's "XSS-safe by construction" line gains the phase-148 note).
2. `frontend/assets/styles.css` — the strip, in the existing doc content column (the core renders into the same `#doc-content` slot the `.doc-raw`/markdown path uses — no new column):
- `.doc-pages { display: grid; gap: <house spacing>; }` (vertical stack — one page per row, the natural reading order for instruction manuals);
- `.doc-page { margin: 0; } .doc-page img { display: block; width: 100%; max-width: 100%; height: auto; border: 1px solid <theme border var>; border-radius: <house radius>; background: <theme surface var>; }` (a page PNG on a dark theme must sit on a non-clashing surface — the image route's figures already have a dark/light precedent to mirror);
- `.doc-page-num { font: <house small meta style>; color: <muted ink var>; text-align: center; padding: <small>; }` (contrast ≥4.5:1 in BOTH themes — the WCAG gate);
- the "Page N unavailable" note reuses the phase-122 image-unavailable note styling (find the existing class — the same muted note, no new pattern).
3. Module docstring: the phase-148 paragraph (the gate, the suppression, the strip, the onerror note, both surfaces) in the house style of the file's leading changelog.
## Testing & Quality
- The house tests `document.js` through E2E + the frontend emoji guard (no JS unit harness) — the rendering pins live in task 04's E2E; this task's gate is that the pre-phase suites (the doc-modal, document-page, image-document, summary-panel E2Es) pass UNCHANGED (a non-qualifying doc is byte-identical — the load-bearing property).
- [ ] a payload with `page_urls` renders the strip (one figure per URL, lazy imgs, "Page N" captions, alt text) with NO text content block and NO Summary panel; the meta badges are untouched
- [ ] a payload without `page_urls` (text docs, images, text-heavy PDFs, pre-phase saved content) renders byte-identically to pre-phase on BOTH surfaces
- [ ] one failed page image swaps only its own figure for the "Page N unavailable" note
- [ ] no innerHTML from document data; the emoji guard stays green
- [ ] `uv run pytest` green (all pre-existing E2Es); `uv run ruff check . && uv run pyright` clean
# Task 04 — the dedicated E2E: a pictorial PDF opens as its pages, a text-heavy PDF as before
**Phase:** `148_pdf_pages_in_sources` · **Source:**`TODO.md:5` — "I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me."
**Story:** n/a
## Objective
End-to-end proof: a synthetic IKEA-shaped PDF (usable text layer, sparse pages → the pictorial class, the owner's named fixture's exact class) syncs, gets its pages, and opens in the doc modal + full page as a strip of real page images — while a text-heavy PDF renders byte-identically to pre-phase.
## Work
1. `tests/e2e/test_pdf_pages_in_sources.py` — the dedicated suite (one Playwright file, run in isolation, mock-only; the module-app pattern of the phase-126/133/140 PDF suites — its own `BOR_SOURCES_DIR` tmp dir + its own port, the LLM the mock; `pdf_pictorial` ON by default — the IKEA class):
- **Fixture builder** (the house PyMuPDF recipe — the `test_pictorial_pdf.py` builder, L214–236, adapted):
- `pictorial.pdf` — the IKEA shape: page 1 dense text (a paragraph, >100 stripped chars — the `is_usable_pdf_text` gate passes on the whole layer), pages 2–3 SPARSE (a drawn rectangle + a line, <100 chars — the `pictorial_pages(texts, min_chars=100)` candidates — the mock vision describes them, the `PICTORIAL_PAGE_ANSWER` canned description);
- `textheavy.pdf` — 2 dense text pages (zero candidates — the negative class).
- Both land in a local-directory source (a `BOR_SOURCES_DIR` subdirectory — local dirs are the owner's named case; a sync run indexes them — the house sync trigger, the admin `Sync sources` click or the API the phase-126 suites use).
- **Test 1 — `test_pictorial_pdf_shows_pages_in_modal`:** after the sync settles, assert via the DB (`SessionLocal`, the house idiom) that the pictorial doc row has `page_count == 3` and three `<id>-p{1..3}.png` files exist under the configured `pdf_page_dir`; open the doc modal from the Sources tree (the house `openDocumentModal`/tree-click idiom the doc-modal E2Es use) → inside the modal content: `.doc-pages` present with EXACTLY three `.doc-page` figures; each `<img>` is LOADED (`img.complete && naturalWidth > 0` — the route served a real PNG, not a 404); the figcaptions read `Page 1..3`; the alt texts carry the doc title; NO `.doc-raw` and NO `.doc-summary` in the modal content (the suppression contract — the mock's page descriptions are in the DB but not displayed); the meta badge row (Created/Indexed) is present.
- **Test 2 — `test_textheavy_pdf_unchanged`:** open `textheavy.pdf`'s modal → the payload has NO `page_urls` (assert through the page: the content endpoint's JSON via the house fetch idiom, or simply the rendering) → the content renders in `.doc-raw` (the pre-phase shape — the pre-existing doc-viewer pins), the Summary panel renders (the mock summary is present) — byte-identical to pre-phase (the phase-10/26/36 pins hold).
- **Test 3 — `test_full_page_surface`:** navigate to the doc's full page (`/document.html?source=…&path=…`, the house URL idiom — admin-logged-in, the phase-79 gate satisfied) → the SAME strip renders (one `renderDocument` core — no per-surface drift) with the same suppression.
- **Test 4 — `test_page_route_serves_png`:** a direct `page.request.get` on the first `page_urls` entry (or a fresh page context) → 200, `content-type: image/png`, the `Cache-Control` header, a non-empty body (the bytes route's contract from the UI's perspective).
2. Teardown: the module app stops; the tmp dirs (sources + `pdf_page_dir`) are disposable (tmp_path_factory); the suite is hermetic (own port, own dirs) so `--no-cov` isolation holds.
## Testing & Quality
- E2E: the four tests above, mock-only, real rendering (PyMuPDF renders the synthetic PDF's pages for real — `naturalWidth > 0` proves the full pipeline: sync → render → persist → route → viewer).
- [ ] the IKEA-shaped pictorial PDF shows all 3 pages as loaded images in BOTH the modal and the full page, with captions/alt text, no text content, no summary panel
- [ ] the text-heavy PDF renders byte-identically to pre-phase (`.doc-raw` + summary panel, no strip)
- [ ] the page route serves a real PNG (200, `image/png`, the cache header, non-empty)
- [ ] `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` green in isolation; `uv run pytest` still green (the pre-existing PDF/doc-viewer suites pass unchanged)
# Phase 149 — sync detail lives in a text box under the button; the button is just the spinner
**Source:** `TODO.md` L6 — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. I get that this was the solution to show information on mobile, but can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** the Sources page (`frontend/index.html`, the `view-rag` section): `#sync-btn` (L465 — the pill: `.sync-icon` + `.sync-label` with the three PARTS — `.sync-label-main` (the shrinks-to-fit prefix + file/folder, the part CSS ellipsizes), `.sync-label-count` (the pinned "(n/m)"), `.sync-label-chunk` (the phase-129 pinned "· chunk n/m")) + `#sync-cancel-btn` (L477, the phase-134 Cancel control) in the `.page-head-row`; `#sync-result` (L500 — the `role="status"` aria-live announcer: the LIVE untruncated label while a sync runs, the settle counts after); `#sync-error-banner` (L502). The single label writer: `frontend/assets/sources.js``setSyncLabel(mainText, countText, chunkText)` (~L759 — writes the parts, then the UNTRUNCATED full label rides the button `title` + `#sync-result` — the A4 announcer contract; EVERY label state goes through it: the running builders `fmtSyncLabel`/`fmtSyncPhaseLabel` (L~810–850: "Syncing… <file> (n/m) · chunk n/m", "Writing KB overview…", "Summarizing folders… <folder> (n/m)"), the idle/settle labels, the 202/409 click, the re-attach). The running state: `sources.js` ~L909 adds `.is-spinning` to the icon (the CSS spin at 1s — `styles.css` L2200, with the `prefers-reduced-motion: none` kill at L2202) and ~L928 removes it on settle; the label's ellipsis lives in `styles.css` (L2216–2239 — `.sync-label-main` min-width 0 + ellipsis; the mobile media query at L5516+ makes the button full-width and the label fill the pill). The owner's complaint: the FULL live label (a long `source/relative/path` + counts + chunk) is clipped in the pill even on desktop — the mobile solution (stuffing the parts in the button) is now the permanent surface.
**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** while a sync/upload run is in flight, the button shows ONLY the spinning icon (the label parts hidden — the button IS the loading spinner, exactly as the owner asked; its `aria-label` carries the full live label for AT), and a NEW bordered text box directly under the button row (`#sync-progress`) shows the SAME full untruncated live label — wrapping, mono, breathing room — hidden at idle/settle. `#sync-result` REMAINS the sole aria-live announcer (the box is presentational, `aria-hidden` — one live region, no double announce); the settle counts line still lands on `#sync-result` exactly as today, and the idle "Sync sources" label returns to the button when the run ends. The box appears for BOTH run kinds (sync + the phase-90 upload "Importing…" run — the shared writer covers both) and re-attaches (a page load mid-run reveals it, the never-stale contract).
## Objective
The live sync detail (file, position, chunk, phase labels) is fully visible in a calm text box under the button at every viewport, the button reduces to a clean spinner while running, and the accessibility contract (one live region, the button's accessible name, focus-visible, contrast) is preserved.
## Dependencies
- `148_pdf_pages_in_sources` (todo) — pipeline order only; no shared code (this phase touches the Sources-page sync UI in `index.html`/`sources.js`/`styles.css`; 148 touches the doc viewer + the importer/docs API).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The box (task 01):**`#sync-progress` — a `<div class="sync-progress" hidden aria-hidden="true">` with an inner `<span class="sync-progress-text" id="sync-progress-text"></span>`, placed in `index.html` BETWEEN the `.page-head` block and `#sync-result` (directly under the button row — the owner's "under the button"). `sources.js`: element constants beside the existing ones (~L707–714); `setSyncLabel` (the SINGLE writer — the invariant: every label state, running or not, flows through here) writes the full label into the box's text AND reveals the box for the RUNNING label states (the running builders' outputs + the re-attach running entry) and HIDES it for every non-running state (idle "Sync sources", the settle "Synced HH:MM" + counts, the 202/409 clicks, the error state) — the reveal/hide decision lives at the `setSyncLabel` callsites via a small `running` flag parameter (or a dedicated `setSyncProgress(full, running)` called from the same sites — the executor picks the minimal clean shape; the CONTRACT is: the box is visible iff a run is in flight, and its text is exactly the full label `setSyncLabel` computes). The button: `#sync-btn` gains `is-running` class while in flight (added where `.is-spinning` is added, ~L909; removed where it is removed, ~L928 — including the error settle and the 409 path); its `aria-label` is set to the full live label while running (the icon-only button needs an accessible name — the `title` already carries it for hover, the aria-label for AT) and restored to `"Sync sources"` on every settle. The Cancel button is untouched (it already rides the running state).
- **The styling (task 02):**`styles.css` — `.sync-progress`: the bordered panel (1px border in the theme border var, the theme surface/background var, the house radius, mono text in `var(--mono)` — the `.sync-label-count`/`.sync-result` mono language: a position reads as data, `overflow-wrap: anywhere` for the long `source/relative/path` (it must WRAP, never clip — the whole point), a comfortable min-height (~2 lines' breathing room, no jump when empty→full because it is hidden when idle), margin below the button row, inside the existing 72rem centered container (the page already rides it — no new width logic); `.sync-btn.is-running .sync-label { display: none; }` (the pill shrinks to an icon-only control — the width adjusts naturally; the `.sync-btn:disabled`/cursor behavior while running is preserved as today); the mobile media query (L5516+): the button stays full-width, the box full-width, the label-hidden rule identical (no mobile special-case — the box REPLACES the old mobile-stuffing reason, which is exactly what the owner asked); `prefers-reduced-motion` untouched (the box is static text; the spinner kill already exists); contrast AA in both themes (the ink vars — the muted `.sync-result` language is ≥4.5:1 already; the box reuses it); focus-visible on the button + Cancel unchanged.
- **The E2E (task 03):** the `slow_llm` proxy idiom (the `test_sync_chunk_progress.py` recipe — a module app with `BOR_LLM_BASE_URL` = the `tests/e2e/slow_llm.py` delay proxy in front of the mock, ~1.5s per LLM call, the fixture repo with a few docs so the import phase has many polls of observable running state) — observe the in-flight state deterministically.
## Tasks
1. `01_progress_box_wiring.md` — the box markup + the `setSyncLabel` wiring (reveal/hide contract) + the button's `is-running` + aria-label.
2. `02_progress_box_styles.md` — the panel + the icon-only running button + mobile + the AA/motion gates.
3. `03_e2e_progress_box.md` — the dedicated E2E: in-flight (spinner-only button + full untruncated box), settled (box hidden, counts line, label restored).
## Testing & Quality
- E2E: one dedicated Playwright file, run in isolation, mock-only (slow proxy).
- Frontend pins: the pre-existing sync E2Es (label parts, chunk progress, mobile label, cancel, summary visibility, upload progress) pass UNCHANGED in their settled/idle assertions; their RUNNING-state assertions that pinned the button label text are updated to the box (the executor finds them via the `sync-label` selectors and migrates the expectation to `#sync-progress` — the A4 announcer (`#sync-result`) is asserted unchanged).
- [ ] while a sync (or upload) run is in flight: the button shows only the spinning icon (no visible label text, at any viewport), its `aria-label` carries the full live label, and `#sync-progress` is visible with the EXACT full untruncated label (a long `source/relative/path` wraps — never clipped)
- [ ] at settle/idle: the box is hidden, the button shows "Sync sources" (the settle "Synced HH:MM" + counts line on `#sync-result` is byte-identical to pre-phase), and the label parts are restored
- [ ] `#sync-result` remains the sole aria-live region (the box is `aria-hidden`); a page load mid-run re-attaches with the box visible (the never-stale contract)
- [ ] WCAG: focus-visible on the button + Cancel, contrast ≥4.5:1 both themes, `prefers-reduced-motion` respected (the spinner kill intact)
- [ ] `uv run pytest` green (all pre-existing E2Es — running-state label pins migrated to the box, settled-state pins untouched); `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the cancel control, the 409 double-trigger, the error banner/modal, the upload run's status, the sync status API)
# Task 01 — the `#sync-progress` box: markup + the single-writer wiring + the icon-only running button
**Phase:** `149_sync_progress_box` · **Source:**`TODO.md:6` — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. … can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
**Story:** n/a
## Objective
The full live sync label is written — by the same single writer that owns the button label — into a text box directly under the button, visible only while a run is in flight; the button reduces to the spinning icon while running (label parts hidden, `aria-label` carrying the full label) and restores its idle label on every settle.
## Work
1. `frontend/index.html` (the `view-rag` section, L458–500): insert BETWEEN the closing `</div>` of `.page-head` and the `#sync-result` span:
```html
<!-- Phase 149 (task 01): the sync detail box — the full UNTRUNCATED live
label ("Syncing… <source/rel/path> (n/m) · chunk n/m") in a bordered
panel under the button row, visible ONLY while a sync/upload run is
in flight (sources.js reveals/hides it through the single setSyncLabel
writer). Presentational (aria-hidden) — #sync-result stays the SOLE
aria-live announcer (one live region, no double announce). The button
itself is the spinner while running (task 02's .is-running CSS hides
(the comment style matches the file's pinned HTML comments; the box is ship-hidden — the anonymous-safe/idle state).
2. `frontend/assets/sources.js`:
- element constants beside the existing block (~L707–714): `const syncProgress = root.querySelector("#sync-progress");` + `const syncProgressText = syncProgress ? syncProgress.querySelector(".sync-progress-text") : null;` (the null-guarded query pattern of the surrounding constants — the elements are only present on the Sources view);
- `setSyncLabel(mainText, countText = "", chunkText = "")` (~L759 — the SINGLE point that writes `#sync-label`): gain a fourth parameter `running = false`. It now ALSO: when `syncProgress` — `syncProgressText.textContent = full` (the SAME untruncated string that rides the title + `#sync-result`; textContent only — the house rule, no HTML) and `syncProgress.hidden = !running` (reveal for a run-in-flight label, hide for everything else). The contract: the box is visible IFF a run is in flight, and its text is EXACTLY the full label;
- update EVERY `setSyncLabel` callsite with the correct `running` flag (the executor enumerates them — they are all in this file): the running builders' outputs (the poll tick's `fmtSyncPhaseLabel`/`fmtSyncLabel` application, the re-attach running entry in `initSyncButton`) → `running=true`; the idle label ("Sync sources"), the settle labels ("Synced HH:MM" + counts), the 202-accepted click label, the 409 double-trigger label, the upload's running labels (the phase-90 "Importing…" run — the box serves it too) → `running=true` while in flight / `false` when that run settles; the error-state label → `running=false`;
- the running-state toggle sites (~L909 the `is-spinning` add / ~L928 the remove — plus every settle path that removes the spin, including the 409 and error settles): add `syncBtn.classList.add("is-running")` / `remove("is-running")` at the SAME sites (the class and the spin travel together — one in-flight state);
- the button's accessible name: while running, set `syncBtn.setAttribute("aria-label", full)` (the full live label — the icon-only button keeps an accessible name; do it in `setSyncLabel` while `running` — the writer already computes `full`); on every non-running `setSyncLabel` call, restore `syncBtn.setAttribute("aria-label", "Sync sources")` (the ship value in the HTML — the static attribute is the idle truth, the JS never removes the attribute, it only overwrites/restores it).
3. `#sync-result`, `#sync-error-banner`, the Cancel button, and the sync modal are UNTOUCHED (the A4 announcer contract: `#sync-result` hears every label exactly as before — the box is a visual duplicate for sighted users).
## Testing & Quality
- The behavioral pins come from task 03's E2E; this task's own gate: the pre-existing sync E2Es' SETTLED-state assertions (idle label, the "Synced HH:MM" + counts line, the 409, the error banner, the cancel lifecycle) pass UNCHANGED — and their running-state assertions that read `#sync-label` text while in flight are migrated to read `#sync-progress` (the executor runs each of the six pre-existing sync E2E files and migrates exactly the running-state selectors — the settled pins must not be touched).
- Coverage: n/a (frontend task).
## Completion Criteria
- [ ] `#sync-progress` sits under the button row, ship-hidden, `aria-hidden`, with the full untruncated live label while any run (sync or upload) is in flight — and hidden in every non-running state (idle, settled, 202/409, error)
- [ ] `#sync-btn` carries `is-running` exactly while in flight; its `aria-label` is the full live label while running and "Sync sources" otherwise
- [ ] `#sync-result` (the sole live region) and every settled-state rendering are byte-identical to pre-phase
- [ ] `uv run pytest` green with the migrated running-state pins; `uv run ruff check . && uv run pyright` clean
# Task 02 — the box styling: the panel, the icon-only running button, mobile, the AA/motion gates
**Phase:** `149_sync_progress_box` · **Source:**`TODO.md:6` — "… can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
**Story:** n/a
## Objective
The box reads as a calm, bordered, mono detail panel with breathing room (long paths WRAP — never clip), and the running button is a clean icon-only spinner control at every viewport — with the WCAG 2.1 AA basics intact in both themes.
## Work
1. `frontend/assets/styles.css` — beside the existing sync-button block (L2138–2240 region):
- `.sync-progress { margin-top: <house spacing>; border: 1px solid <theme border var>; background: <theme surface var>; border-radius: <house radius>; padding: <comfortable — the "breathing room">; min-height: <~2 lines>; }` — the panel language of the house's existing bordered surfaces (mirror the nearest precedent — e.g. the `.kb-banner`/token-card border treatment — so it belongs to the theme);
- `.sync-progress-text { font-family: var(--mono); font-size: <the .sync-result size — the mono data language>; color: <the .sync-result ink — ≥4.5:1 in BOTH themes, the house muted-ink var>; overflow-wrap: anywhere; display: block; line-height: <house>; }` — `overflow-wrap: anywhere` is the point: a long `source/relative/path` wraps inside the panel instead of clipping (the pre-phase ellipsis is retired for the live detail);
- **the running button:**`.sync-btn.is-running .sync-label { display: none; }` (the pill shrinks to the icon — the spinner is the whole button while in flight; the pill's padding/border stay, so the control does not jump to zero size — check the `.sync-btn` box model at L2138 and keep a sane icon-only footprint, e.g. the icon centered with the pill's padding — the executor verifies visually via the E2E screenshots); the button keeps its `:disabled` styling/cursor while running (the pre-phase double-trigger posture — whatever disabled state it has in flight today is preserved, only the label is hidden);
- **focus-visible + hover** on the running (icon-only) button are unchanged (the pre-phase rules apply to the button element — hiding the label span touches neither);
- **mobile media query** (L5516+ region, the `.sync-btn { width: 100% }` block): the box inherits full width naturally (it is a block in the same container — assert no width override is needed); the `.is-running` label-hidden rule is viewport-agnostic (defined once, applies to both) — DELETE nothing from the mobile label rules unless they become dead (the `.sync-label` parts are still rendered at idle — the mobile ellipsis rules for the IDLE label stay; if a rule references only the running state, the executor notes it in the commit, no deletion of idle rules);
- **`prefers-reduced-motion`:** untouched (the box is static; the spinner's existing `animation: none` kill at L2202 stands);
- **both themes:** every color via the CSS vars (the dark/light theme system — the phase-91/92/93 theme variables; a hardcoded hex would break the semantic theme completion pins).
2. Verify (via the task-03 E2E, not here): the panel's contrast in both themes (the E2E can assert the computed color contrast is ≥4.5:1 against the panel background — the house contrast-check idiom if one exists in the theme E2Es; else a visual pin + the var-based construction argument in the commit).
## Testing & Quality
- The visual/behavioral pins live in task 03's E2E (running: icon-only button + the full wrapped label in the panel, desktop AND mobile viewports; settled: idle pill restored). The pre-existing sync E2Es' settled assertions must pass UNCHANGED.
- Coverage: n/a (frontend task).
## Completion Criteria
- [ ] the box is a bordered mono panel with the full label WRAPPING (no ellipsis, no horizontal clip) at desktop and mobile widths
- [ ] the running button shows only the spinner (label span `display: none` via `.is-running`), with an icon-only footprint that does not collapse, and restored "Sync sources" pill at idle
- [ ] focus-visible, hover, disabled, and `prefers-reduced-motion` behaviors unchanged; contrast ≥4.5:1 both themes (var-based)
- [ ] `uv run pytest` green (all sync E2Es); `uv run ruff check . && uv run pyright` clean
# Task 03 — the dedicated E2E: spinner-only button + the full untruncated box while running, the restored pill at settle
**Phase:** `149_sync_progress_box` · **Source:**`TODO.md:6` — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. … can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
**Story:** n/a
## Objective
End-to-end proof through the real sync path (real `git clone` of a local `file://` fixture repo, the slowed mock LLM so the in-flight state is observable): while running, the button is the spinner and the box carries the FULL untruncated live detail (a long path included); on settle, the box is gone and the pre-phase settle rendering stands.
## Work
1. `tests/e2e/test_sync_progress_box.py` — the dedicated suite (one Playwright file, run in isolation, mock-only; the `test_sync_chunk_progress.py` module-app recipe — the module app with `BOR_GIT_SOURCES=file://<fixture repo>` + its own `BOR_SOURCES_DIR` + its own port (env-overridable house constant) + `BOR_LLM_BASE_URL` = the `tests/e2e/slow_llm.py` delay proxy in front of the mock (`SLOW_DELAY_S = "1.5"` — the proxy delays EVERY LLM call, so the import phase yields many 2s-poll ticks of observable running state)):
- **Fixture:** the house fixture-repo builder with a doc whose RELATIVE PATH IS LONG (e.g. `notes/a-rather-deeply-nested-folder/structure/for/the/sync/fixture-document.md` — the clipping trigger the owner hit: a `source/relative/path` that overflows the pill at desktop width) + one or two short-path docs (the sync has several files → the counts/chunk parts are exercised too).
- **Test 1 — `test_running_button_is_spinner_only`:** admin login; click `#sync-btn`; poll (the house `expect(...).to_be_visible` with a generous timeout — the slow proxy guarantees a long in-flight window) until `#sync-progress` is visible (a run is in flight) → assert:
- `#sync-btn .is-running` present + `#sync-btn .sync-icon.is-spinning` present (the spinner — the loading state);
- `#sync-label` is NOT visible (`locator("#sync-label").is_visible() == false` — the label parts are hidden: the button is the icon);
- `#sync-btn`'s `aria-label` attribute equals the current full live label (read `#sync-progress-text`'s text — they are written by the same call; assert equality, never staleness);
- the Cancel button is visible (the phase-134 contract unchanged).
- **Test 2 — `test_box_carries_the_full_untruncated_label`:** during the in-flight window of the LONG-path file (poll until `#sync-progress-text` contains the long fixture path — the slow proxy makes this deterministic):
- `#sync-progress-text`'s textContent contains the FULL label shape `Syncing… <full long path> (<d>/<t>)` — the path string in FULL (assert the complete relative path is a substring — the pre-phase ellipsis would have truncated it; the wrap means nothing is dropped);
- the box is NOT clipped: `syncProgress.scrollWidth <= syncProgress.clientWidth` (no horizontal overflow — the wrap engaged) AND `syncProgressText.scrollWidth <= syncProgressText.clientWidth` (the text itself did not overflow its container);
- `#sync-result` (the announcer) carries the SAME full string (the A4 contract — one source, two surfaces);
- at some tick the chunk part is present (`· chunk <n>/<m>` — the phase-129 part, now in the box) — assert with a retry loop over the slow embed window (the chunk part is only present while an embed batch is in flight — the slow proxy's 1.5s delay makes the window wide).
- **Test 3 — `test_mobile_box_full_width` (the viewport half of the owner's ask):** a fresh context at the mobile viewport (the house mobile-width constant the `test_sync_label_mobile.py` uses) → same in-flight assertions: the box is full-width (its bounding box width ≈ the container width minus margins — no horizontal clipping, `scrollWidth <= clientWidth`), the button is icon-only, the label wraps (the old mobile-stuffing ellipsis is gone — assert the full path substring again).
- **Test 4 — `test_settled_state_restores_the_pill`:** let the run settle (the `Synced HH:MM` pattern on `#sync-result` — the house settle idiom):
- `#sync-progress` is HIDDEN (`is_visible() == false`) — the box is run-scoped only;
- `#sync-btn` has NO `is-running`, the icon has NO `is-spinning`, and `#sync-label` is VISIBLE with the settle label ("Synced HH:MM" — the pre-phase byte-identical settle copy); the `aria-label` is back to "Sync sources";
- the counts line on `#sync-result` matches the pre-phase shape (`N added` etc. — the `fmtSyncResult` pin).
- **Test 5 — `test_reattach_shows_the_box`:** trigger a sync, wait until in-flight (the box visible), then RELOAD the page (the admin session persists) → the load-time re-attach (`initSyncButton`) finds the RUNNING sync → the box is visible with the current label and the button is icon-only again (the never-stale contract — a page load mid-run shows the truth, not an idle pill).
2. Teardown per the house module-app fixture (app stopped, tmp dirs disposable); hermetic (own port + dirs) so `--no-cov` isolation holds.
## Testing & Quality
- E2E: the five tests above, mock-only (slow proxy), real git + real import; the load-bearing assertions are the FULL-path substring (no clipping) + the wrap proof (`scrollWidth <= clientWidth`) + the settle byte-identity.
- Coverage: n/a (E2E task).
## Completion Criteria
- [ ] in-flight: the button is the spinner only (label hidden, `is-running`, `aria-label` == the full label), the Cancel button rides along, and the box shows the EXACT full untruncated label — long path included, wrapped, `scrollWidth <= clientWidth`, desktop AND mobile
- [ ] the announcer (`#sync-result`) carries the same full string; the chunk part appears in the box during the embed window
- [ ] settled: the box hidden, the "Synced HH:MM" pill restored byte-identical, `aria-label` back to "Sync sources", the counts line unchanged
- [ ] a page load mid-run re-attaches with the box visible (never stale)
- [ ] `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation (DB up, git on PATH — the documented prerequisite); `uv run pytest` still green (the six pre-existing sync E2Es — migrated running-state pins + untouched settled pins)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.