chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -0,0 +1,56 @@
# Phase 59 — Save Chat Answers as Docs (edit screen → push to .env branch)
**Source:** `TODO.md` L3 — "Convert response to documentation that gets committed back to a repo specified in .env. In the bottom right of each llm response, have a save button that takes you to an edit screen and allows you to modify the new documentation before opening a PR to the specified repo with the new documentation. This way you can create updated documentation from the chat responses to keep the RAG fresh."
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-31, D3)
**Context:** Completed brain bubbles carry a `.msg-meta` row (`styles.css` ~L625 — flex, wrap) into which `appendTuneButton` (`frontend/assets/app.js:382`, admin-gated) and `appendRetryButton` (`app.js:455`) inject actions; the call sites are the live render (~L1815) and the restored render (~L1093), where the persisted message's raw markdown is in scope as `m.text` (HTML is never persisted — the `bor.chat.v1` record). Boot gating already runs on `GET /api/whoami` (`app/api/auth.py:56`) and `GET /api/config` (`app/api/config.py` — currently `app_name`/`version` only). The A11 git primitive is `scripts/git_sync.py` (`clone_or_pull` + private `_run`, stdlib `subprocess` only — no new packages); `app/api/git_sources.py` maps `GitSyncError` to user-facing `detail`. The `saved_chats` model/migrations 0008/0009 are the house pattern for a new table. **Owner revision of the TODO (D3, 2026-08-31): no PR tooling at all — commit + push to a new branch named in `.env`, generic git remote (local path or any host), the owner opens the PR themselves.** `.agent/` is untracked (owner commit 281f355) — phase commits stage `app/ frontend/ tests/` only.
## Objective
Every completed brain answer gets a bottom-right "Save as doc" action (admin, only when a docs repo is configured) → an edit screen (title, in-repo path, markdown body) → commit + push to the `.env`-specified branch of the `.env`-specified repo — generic, fail-loud, and inert (button hidden) while `BOR_DOCS_REPO` is empty.
## Dependencies
- `58_wide_desktop_column` (todo, preceding — no functional dependency; ordering by size)
## Tasks
1. `01_env_and_drafts_model.md` — `BOR_DOCS_REPO`/`BOR_DOCS_BRANCH`/`BOR_DOCS_BASE_BRANCH`/`BOR_DOCS_WORK_DIR` settings + `doc_drafts` table (migration 0011) + schemas + `.env.example` section.
2. `02_drafts_api.md` — admin-only drafts CRUD: `POST /api/doc-drafts`, `GET`/`PUT /api/doc-drafts/{token}` (path guard-rails, token = uuid4).
3. `03_docs_push_service.md` — `app/core/docs_push.py`: clone-or-ensure checkout, base re-sync, `checkout -B <branch>`, write file, commit, `push --ff-only` (push **only** — D3), unit-tested against a local bare repo.
4. `04_push_endpoint.md` — `POST /api/doc-drafts/{token}/push`: 409 unconfigured, `DocsPushError` → 502 with git stderr, stores branch + sha.
5. `05_save_as_doc_button.md` — bottom-right meta-row button (admin + configured), POSTs the raw `m.text`, navigates to the edit screen with a default title/path.
6. `06_edit_screen.md` — `frontend/doc-edit.html` + `doc-edit.js`: admin-gated form (title, path, body), Push → branch + commit feedback, error banner, no CDN.
7. `07_e2e_response_to_docs.md` — story Playwright suite (local bare-repo fixture, verified with real `git` commands) + regressions + the atomic commit.
## Testing & Quality
- Unit: config (unset = inert; invalid branch tokens fail loud at startup), `docs_push` against a `tmp_path` local bare git repo (push, ff re-push, non-ff failure, missing-repo failure — `git --version` skipif guard).
- Integration: `tests/integration/test_doc_drafts_api.py` (CRUD + 403/404 + path guard-rails) and the push endpoint (success / 409 / 502 / 403).
- Frontend source pins (house pattern): `app.js` (gating, right alignment, payload, `m.text` passthrough), `doc-edit.html`/`doc-edit.js` (gating, API paths, feedback contract), `styles.css` (new classes).
- E2E (mandatory, A16): `tests/e2e/test_response_to_docs.py`, run in isolation — the source of truth is the **bare repo's state** (`git show <branch>:<path>`, `rev-list`), not the UI alone.
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] Admin with `BOR_DOCS_REPO` set: the bottom-right "Save as doc" button appears on every completed brain bubble (live + restored); clicking it opens `/doc-edit.html?draft=<token>` prefilled (auto-title from the last question, path `docs/<slug>.md`, body = the answer markdown).
- [ ] Pushing from the edit screen creates/updates the file on the `.env` branch of the `.env` repo: `git -C <repo> show <branch>:<path>` equals the saved body; the UI shows the branch + commit sha matching `git rev-parse <branch>`; a second save fast-forwards (2 commits on the branch).
- [ ] The branch is created on first push; pushes are `--ff-only` (a concurrently-advanced remote branch fails loudly with git's stderr in the error banner — no silent merge).
- [ ] No PR is ever created or attempted: no `gh`, no PR URL, no GitHub-specific logic anywhere in `app/` or `frontend/`.
- [ ] `BOR_DOCS_REPO` empty → no button for anyone; `POST /api/doc-drafts/{token}/push` → 409 naming the variable.
- [ ] Anonymous: no button; all four draft endpoints + push → 403.
- [ ] `uv run pytest` green; coverage TOTAL >90%.
- [ ] `uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_chat_rag.py`, `test_admin_auth.py`, `test_cache_busting.py`, `test_chat_history.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/` (`.agent/` stays untracked — owner instruction, commit 281f355).
## Locked decisions
- **Owner-locked (2026-08-31, roadmap confirmation, D3):**
1. **No PR creation.** The flow ends at `git push --ff-only` to a **new branch named in `.env`** (`BOR_DOCS_REPO` + `BOR_DOCS_BRANCH`); the owner opens the PR themselves.
2. **Generic remote** — local path, `https://`, `ssh://`, any host: the A11 `subprocess`-only git discipline (`scripts/git_sync.py`); no `gh`, no API tokens, no URL construction, no GitHub assumption.
3. Admin-only feature; inert (button hidden, push 409s) while `BOR_DOCS_REPO` is empty — same optional-feature pattern as the git-sources env fallback.
- **ASSUMPTION (default values — all overridable in `.env`, flagged for owner awareness):**
- `BOR_DOCS_BRANCH` default `bor-docs`; `BOR_DOCS_BASE_BRANCH` default `main`; `BOR_DOCS_WORK_DIR` default `~/bor-docs` (kept separate from `BOR_SOURCES_DIR` by the upload-dir convention).
- Commit identity is fixed per-invocation (`-c user.name="Brain of Reese" -c user.email="bor@local"`) — no reliance on global git config; commit message `docs: <title>`.
- Re-pushing an edited draft = a **new commit** on the same branch overwriting the same file (no force-push, ever).
- Default doc title = the whitespace-collapsed ≤120-char text of the **last user question** (the chat auto-title convention, phase 50); the edit screen changes anything.
## Commit
```bash
git add app/ frontend/ tests/ .env.example && git commit --no-gpg-sign -m "feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch"
```
@@ -0,0 +1,41 @@
# Task 01 — Docs-push env settings + `doc_drafts` model
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "Convert response to documentation that gets committed back to a repo specified in .env."
**Story:** n/a (TODO-derived)
## Objective
The feature's configuration surface (`.env`) and its persistence backbone (the server-side draft a long answer body must live in — never the URL) exist, validated fail-loud and inert-by-default.
## Work
1. `app/config.py` (new "Docs push (phase 59)" settings block, house comment style):
- `docs_repo: str = ""` — `BOR_DOCS_REPO`; git URL **or local path** (D3: generic remote). Empty = feature off.
- `docs_branch: str = "bor-docs"` — `BOR_DOCS_BRANCH`; the branch pushes land on.
- `docs_base_branch: str = "main"` — `BOR_DOCS_BASE_BRANCH`; the branch each push bases off.
- `docs_work_dir: str = "~/bor-docs"` — `BOR_DOCS_WORK_DIR`; the checkout dir (raw string — `Path.expanduser()` applied by the service, the `sources_dir`/`upload_dir` convention).
- `@field_validator` (all-or-nothing check): when `docs_repo` is non-empty, `docs_branch` and `docs_base_branch` must be non-empty after strip and must not contain whitespace or `..` (fail loud at startup, `agent_max_rounds`-style).
- `@property docs_configured: bool` — `bool(self.docs_repo.strip())`.
2. `alembic/versions/0011_doc_drafts.py` + `app/models.py` — `DocDraft` (follow the `saved_chats` model/migration style, 0008/0009):
- `id` UUID PK (uuid4 default), `token` UUID unique NOT NULL (the URL credential — unguessable, the share-token trust model), `title`/`path`/`body` TEXT NOT NULL, `status` TEXT NOT NULL default `'draft'` (`draft` | `pushed`), `branch`/`commit_sha` TEXT NULL, `created_at`/`updated_at` TIMESTAMPTZ NOT NULL (now defaults).
3. `app/schemas.py`: `DocDraftCreate {title, path, body}`; `DocDraftUpdate {title?, path?, body?}`; `DocDraft` (full row, ISO datetimes); `DocDraftPushed {status: "pushed", branch, commit_sha}`.
4. `.env.example` — new commented section after the git-sources block:
```
# --- Docs push (phase 59: save a chat answer as documentation) ---
# The git repo chat answers can be committed to — any remote (URL or
# local path). While empty, the "Save as doc" action is hidden and the
# push endpoint 409s. Commits land on BOR_DOCS_BRANCH (push --ff-only);
# open the PR yourself.
# BOR_DOCS_REPO=/path/to/docs-repo
# BOR_DOCS_BRANCH=bor-docs
# BOR_DOCS_BASE_BRANCH=main
# BOR_DOCS_WORK_DIR=~/bor-docs
```
## Testing & Quality
- Unit (config test file): unset repo → `docs_configured False`, no startup error (branch/base defaults fine); repo set + branch `"bor docs"` (whitespace) or `"a..b"` → startup `ValueError` naming the field; repo set + valid values → `docs_configured True`.
- Migration: `uv run alembic upgrade head` applies 0011 cleanly on a fresh test DB; the existing migration pin (if any — follow the house pattern) extended for the new table.
## Completion Criteria
- [ ] Four settings + `docs_configured` property, fail-loud validation, inert-by-default.
- [ ] `doc_drafts` table live (migration 0011) with the token as a unique unguessable credential.
- [ ] `.env.example` documents the block with defaults.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,28 @@
# Task 02 — Admin drafts API (create / get / update)
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "… have a save button that takes you to an edit screen and allows you to modify the new documentation …"
**Story:** n/a (TODO-derived)
## Objective
The draft lifecycle the edit screen runs on: create (from a response), fetch by token, update (modify before push) — all admin-only, all path-guard-railed.
## Work
1. `app/api/doc_drafts.py` — `APIRouter(dependencies=[Depends(require_admin)])` (the router-wide pattern from `app/api/chats.py`):
- `POST /api/doc-drafts` → 201 `DocDraft`. Body `DocDraftCreate`.
- `GET /api/doc-drafts/{token}` → `DocDraft`; unknown token → 404 `{"detail": "draft not found"}`.
- `PUT /api/doc-drafts/{token}` → `DocDraft`. Body `DocDraftUpdate` (partial — absent fields unchanged); bump `updated_at`.
- Shared path guard (module helper, used by POST/PUT **and** the push endpoint): non-empty after strip; not absolute; every component non-`.`/`..` (`Path(p).parts` check — `"../x.md"`, `"/etc/passwd"`, `"a/b/../c.md"` all rejected); must carry a suffix (`.Path(p).suffix` non-empty). Violation → 422 with a `detail` naming the rule.
- `title`/`body` must be non-empty after strip (422). `token = uuid4` on create.
- An edit on a `pushed` draft resets `status` to `'draft'` (the stored sha no longer describes the current body — the next push re-commits; D3 ASSUMPTION).
2. `app/main.py`: include the router (the existing `include_router` block).
## Testing & Quality
- `tests/integration/test_doc_drafts_api.py`:
- create → 201 with all fields + `status="draft"` + non-NULL token; get round-trips; partial update (body only) keeps title/path, resets a pushed draft to `draft` (set `status='pushed'` directly in the DB first).
- Path guard-rails: each of `/etc/passwd`, `../x.md`, `a/b/../c.md`, `no-suffix` → 422 with the named rule; `docs/note.md` accepted.
- 403 anonymous on all three routes (no cookie); 404 unknown token on GET/PUT.
## Completion Criteria
- [ ] The three endpoints behave per the pins; no draft can be created/edited with a path that escapes the repo root.
- [ ] Guest access is impossible (router-wide dependency).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,43 @@
# Task 03 — `app/core/docs_push.py`: commit + push to the .env branch
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "… documentation that gets committed back to a repo specified in .env."
**Story:** n/a (TODO-derived)
## Objective
One fail-loud, generic (any git remote), A11-stdlib-only function that commits a file into the configured docs repo and pushes it `--ff-only` to the configured branch — **no PR, no `gh`, no URL construction** (D3).
## Work
1. `scripts/git_sync.py`: promote the private `_run` to a **public** `run_git(argv: list[str], cwd: Path) -> str` (keep behavior verbatim — captured stdout, `GitSyncError` with git's stderr on non-zero exit, `FileNotFoundError` → "git was not found on PATH"); update `__all__`; `clone_or_pull` now calls `run_git`.
2. `app/core/docs_push.py`:
- `class DocsPushError(RuntimeError)` — carries git's stderr (the `GitSyncError` style).
- ```python
def push_document(
repo: str, base_branch: str, branch: str, work_dir: str,
rel_path: str, content: str, commit_message: str,
) -> tuple[str, str]:
"""Commit *content* at *rel_path* and push *--ff-only* to *branch*.
Returns (branch, full_sha). Raises DocsPushError (git stderr) on
any failure — the checkout is left as-is for inspection."""
```
Exact step sequence (each step via `run_git`, `cwd=work_dir` after setup):
1. `work = Path(work_dir).expanduser()`; if `work / ".git"` is absent → `work.parent.mkdir(parents=True, exist_ok=True)` + `git clone --depth 1 --branch <base_branch> <repo> <work>` (the base branch is explicit — the remote's default may differ).
2. `git fetch --depth 1 origin <base_branch>` (re-sync the base before every push).
3. Attempt `git fetch --depth 100 origin <branch>` — **failure is expected** when the branch does not exist on the remote yet (swallow and continue); success leaves `FETCH_HEAD` pointing at the existing branch.
4. `git checkout -B <branch> FETCH_HEAD` (new branch from the fresh base, or re-attach onto the previously pushed branch — its history, so `--ff-only` works).
5. Write the file: `(work / rel_path).parent.mkdir(parents=True, exist_ok=True)`; `write_text(content)` (rel_path was guard-railed upstream — re-assert the parts check defensively).
6. `git add -- <rel_path>`; `git -c user.name="Brain of Reese" -c user.email="bor@local" commit -m <commit_message>` (ASSUMPTION: fixed per-invocation identity — no global-config reliance).
7. `git push --ff-only origin <branch>` (creates the remote branch on first push; a concurrently-advanced remote → non-ff → `DocsPushError`, **never** a force-push or a merge).
8. `sha = run_git(["rev-parse", "HEAD"], work).strip()`; return `(branch, sha)`.
- Module docstring states D3 explicitly: push only, generic remote, no PR tooling.
3. Unit tests `tests/unit/test_docs_push.py` (real local git in `tmp_path`; `pytest.skip` guard on `git --version` failure):
- fixture: `git init --bare bare.git` + a work clone seeded with one commit on `main` (`README.md`) pushed to the bare repo as `main`.
- `push_document(repo=<bare>, base_branch="main", branch="bor-docs", …)` → returns `("bor-docs", sha)`; `git -C bare show bor-docs:<rel_path>` == content; `bor-docs` exists in the bare repo with exactly 1 commit beyond `main`.
- Second push (edited content, same path) → `git rev-list --count main..bor-docs` == 2 (fast-forward, no force).
- Non-ff: advance the bare `bor-docs` from a second clone, then push from the first → `DocsPushError` whose message contains git's stderr.
- Missing repo path / non-repo dir → `DocsPushError`.
- `run_git` promotion: `clone_or_pull` regression stays green (existing tests).
## Completion Criteria
- [ ] `push_document` is the ONLY place (besides `git_sync`) that invokes git for this feature; no network, no `gh`, no URL building.
- [ ] Push is `--ff-only` on every path; first push creates the branch; failures raise with git's stderr.
- [ ] Unit suite green against a local bare repo; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,33 @@
# Task 04 — `POST /api/doc-drafts/{token}/push`
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "… committed back to a repo specified in .env."
**Story:** n/a (TODO-derived)
## Objective
The single mutation the edit screen triggers: take the draft's (title, path, body), commit + push through `push_document`, and record the outcome on the draft.
## Work
1. `app/api/doc_drafts.py` — new route on the existing admin-gated router:
```python
@router.post("/{token}/push", response_model=DocDraftPushed)
def push_doc_draft(token: uuid.UUID, db: Session = Depends(get_db)) -> DocDraftPushed: ...
```
- 404 unknown token.
- `settings.docs_configured` false → 409 `{"detail": "docs repo not configured (BOR_DOCS_REPO)"}` (D3: inert by default).
- Re-validate the draft's `path` with the task-02 guard-rails (a row must not be pushable into a bad path — 422).
- `branch, sha = push_document(settings.docs_repo, settings.docs_base_branch, settings.docs_branch, settings.docs_work_dir, draft.path, draft.body, f"docs: {draft.title}")`.
- On success: `draft.status = "pushed"`, `draft.branch = branch`, `draft.commit_sha = sha`, bump `updated_at`; commit; return `DocDraftPushed(status="pushed", branch, commit_sha=sha)`.
- `DocsPushError` → 502 with `detail=str(exc)` (git's stderr — the `GitSyncError` → `detail` mapping from `app/api/git_sources.py`), draft row untouched.
- `Depends(get_settings)` for the settings (house pattern — follow `app/api/config.py`).
## Testing & Quality
- `tests/integration/test_doc_drafts_api.py` (extend): local bare-repo fixture in `tmp_path` (the task-03 unit pattern), `Settings(_env_file=None, docs_repo=<bare>, docs_branch="bor-docs", docs_base_branch="main", docs_work_dir=<tmp>)` injected into the app's settings dependency:
- success → 200 `{status: "pushed", branch: "bor-docs", commit_sha}`; DB row `status="pushed"` + sha; `git -C bare show bor-docs:<path>` == body.
- `docs_repo=""` (default settings) → 409 naming the variable.
- `docs_repo` pointed at a non-repo dir → 502 with git's stderr in `detail`; row still `draft`.
- anonymous → 403.
## Completion Criteria
- [ ] The endpoint's four outcomes (success / 409 unconfigured / 502 git failure / 403) are pinned; only a success mutates the draft.
- [ ] The returned sha equals `git rev-parse <branch>` (asserted via the bare repo in the test).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,32 @@
# Task 05 — Bottom-right "Save as doc" on every brain response
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "In the bottom right of each llm response, have a save button that takes you to an edit screen."
**Story:** n/a (TODO-derived)
## Objective
A right-aligned "Save as doc" action in each completed brain bubble's meta row (admin, only when `BOR_DOCS_REPO` is configured) that opens the edit screen with the answer preloaded as a draft.
## Work
1. `app/api/config.py`: add `"docs_repo_configured": settings.docs_configured` to the `app_config` dict (the boot fetch already calls `/api/config` for branding — no new network surface).
2. `frontend/assets/app.js`:
- Boot: capture `docs_repo_configured` wherever the `/api/config` response is consumed (the `brand.js`/boot-IIFE fetch — follow the existing wiring; if the fetch lives in `brand.js`, surface the flag the same way `app_name` is surfaced).
- `appendSaveAsDocButton(wrap, markdown)` — the `appendTuneButton` (`app.js:382`) pattern: reuse `.msg-meta` when present (create it, `role="list"`, otherwise); the button `.save-as-doc-btn` carries `margin-inline-start: auto` (the **bottom-right** requirement) and a house icon + label "Save as doc".
- Visibility: **admin** (the whoami gate Tune uses) **and** `docs_repo_configured` — otherwise the function injects nothing (deflected bubbles included, same scope as Tune; `m.stopped` markers do not gain the button — the note is not an answer).
- Click:
- `title` = the whitespace-collapsed ≤120-char text of the **last user question** (reuse the existing auto-title helper — the phase-50 convention);
- `path` = `"docs/" + slug(title) + ".md"` (slug: lowercase, runs of non-alphanumeric → `-`, trimmed, ≤60 chars, empty → `note`);
- `POST /api/doc-drafts {title, path, body: markdown}` → 201 → `location.assign("/doc-edit.html?draft=" + <token>)`;
- failure → neutral one-line error copy (phase-55 convention), the conversation unblocked, no navigation.
- Call sites: after `appendTuneButton(wrap)` in the **live** render (~L1815) and the **restored** render (~L1093) — pass `m.text` (the raw persisted markdown — **never** the rendered HTML).
3. `frontend/assets/styles.css`: `.save-as-doc-btn` — the `.tune-btn` visual language (size, palette, `:focus-visible`) plus `margin-inline-start: auto`.
## Testing & Quality
- Frontend source pins (house pattern):
- `app.js`: the flag gate (button absent without admin **or** without `docs_repo_configured`), the `margin-inline-start: auto` / right-alignment class, the POST payload shape (`title`/`path`/`body: m.text`), the `location.assign("/doc-edit.html?draft=" + …)` navigation, the slug rule.
- `styles.css`: `.save-as-doc-btn` present with the right-alignment declaration.
- `app/api/config.py` unit pin: the response dict gains `docs_repo_configured` (bool, tracks `settings.docs_configured`).
## Completion Criteria
- [ ] The button appears bottom-right on every completed brain bubble (live + restored, deflected included) for a configured admin — and nowhere else.
- [ ] Click → edit screen, prefilled; the raw markdown (not HTML) is what the draft stores.
- [ ] Unit pins green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,26 @@
# Task 06 — The edit screen (`/doc-edit.html`)
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "… takes you to an edit screen and allows you to modify the new documentation before …"
**Story:** n/a (TODO-derived)
## Objective
A standalone, admin-gated flow page: load a draft by token, edit title / in-repo path / markdown body, and push — with the "never stale" feedback contract (live-region status + error banner, phase 55 convention).
## Work
1. `frontend/doc-edit.html` — house shell (no CDN, system fonts, AA palette, `:focus-visible`; the `login.html`/`shared.html` minimal-flow-page lineage):
- skip-link; a slim header (brand + "← Back to chat" link to `/`); `<main class="app-main" id="main">` with a `container` holding the **46rem base column** (a form, not a reading column — it does not use `--chat-column`).
- Form fields (labels + `id`s): `#draft-title` (text), `#draft-path` (text, mono), `#draft-body` (textarea, mono, `min-height: 20rem`).
- Actions: `#push-doc-btn` (primary, "Push to docs branch") + the back link.
- Feedback: `#push-status` (`role="status" aria-live="polite"`) and a hidden error banner `#push-error` (`role="alert"`).
2. `frontend/assets/doc-edit.js`:
- Boot: `GET /api/whoami` → non-admin → the admin gate (the `sources-gate` pattern — the page is static, the API is the authority). Otherwise read `?draft=<token>` (missing → error banner "No draft specified.") → `GET /api/doc-drafts/<token>` (404 → "Draft not found.") → fill the three fields (values, never `innerHTML`).
- Push: on `#push-doc-btn` → disable the button + status "Pushing…" → `POST /api/doc-drafts/<token>/push`.
- Success → status: `Pushed to <branch> — commit <sha7>.` (the sha truncated for display, full value from the API); re-enable the button (a re-push after further edits is a new commit — D3 ASSUMPTION).
- Failure → `#push-error` banner with the API `detail` (git's stderr — trimmed to the first meaningful lines), fields preserved, button re-enabled.
- Minimal client-side sanity (non-empty title/body, no `..` in path) with the **server** as the authority.
3. Frontend source pins (house pattern): `doc-edit.html` (field ids, live region, alert banner, no external `src=`/`href=`), `doc-edit.js` (whoami gate, the three API paths, success/failure handling, value-not-innerHTML).
## Completion Criteria
- [ ] Non-admin visitors get the gate (no draft data leaks — the draft API is admin-only regardless).
- [ ] The edit loop (load → modify → push) works on the three fields; success shows branch + commit; failures show the error banner with git's detail and keep the user's edits.
- [ ] Unit pins green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,29 @@
# Task 07 — E2E: response → edit → push (verified against real git) + commit
**Phase:** `59_response_to_docs_push` · **Source:** `TODO.md:3` — "Convert response to documentation … modify the new documentation before … [pushing] to the specified repo with the new documentation. This way you can create updated documentation from the chat responses to keep the RAG fresh."
**Story:** n/a (TODO-derived)
## Objective
Prove the whole loop in the browser with the **bare repo as source of truth** (git commands, not UI text), run the regressions, and commit the phase.
## Work
1. `tests/e2e/test_response_to_docs.py` (Playwright, DB up, mock LLM, local git; app boot + env-override pattern from `tests/e2e/conftest.py`; admin flow from `test_git_sources_admin.py`):
- Fixture (per suite): `git init --bare <tmp>/docs.git` + a seeded work clone with one commit on `main` (`README.md`) pushed as `main`; app env: `BOR_DOCS_REPO=<tmp>/docs.git`, `BOR_DOCS_BRANCH=bor-docs`, `BOR_DOCS_BASE_BRANCH=main`, `BOR_DOCS_WORK_DIR=<tmp>/docs-work`; admin login via `e2e.auth_helpers.login`.
- `test_save_edit_push` — ask an on-topic question (the `test_chat_rag.py` phrasing pattern), wait for `done` → the "Save as doc" button is visible in the brain bubble's meta row, right-aligned (assert `bounding_box` x > bubble midline) → click → URL is `/doc-edit.html?draft=<uuid>`; fields prefilled: title = the auto-title (whitespace-collapsed ≤120 chars of the question), path `docs/<slug>.md`, body == the rendered answer's **markdown source** (the mock LLM's deterministic answer text) → append a distinctive marker line to the body → Push → the status live region shows branch `bor-docs` + a commit sha; **git verification**: `git -C <bare> show bor-docs:docs/<slug>.md` equals the edited body; the UI's sha prefix equals `git -C <bare> rev-parse bor-docs` (7 chars).
- `test_second_push_fast_forwards` — a second answer → save → push without editing → `git -C <bare> rev-list --count main..bor-docs` == 2; the first file is still at `bor-docs:<path-1>`.
- `test_guest_has_no_button` — fresh context (no login): no "Save as doc" button; `httpx POST /api/doc-drafts` without cookie → 403; a direct `GET /doc-edit.html` renders the admin gate (no draft data in the DOM).
- `test_unconfigured_hides_button` — a second app boot **without** `BOR_DOCS_REPO` (separate fixture): admin sees no button; `POST /api/doc-drafts` (admin cookie, valid body) → 201 allowed (drafts are repo-independent) but `POST …/push` → 409 naming `BOR_DOCS_REPO`.
2. `tests/e2e/test_cache_busting.py`: add `/doc-edit.html` to the walked page list (the phase-50 pattern — the no-cache + `?v=` contract applies to the new page).
3. Regression pass (isolation runs): `test_chat_rag.py` (meta-row change on every bubble), `test_admin_auth.py` (whoami gate unchanged), `test_cache_busting.py` (after the page-list addition), `test_chat_history.py` (restored-render path gained the button call).
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
5. Commit (Conventional Commits, `--no-gpg-sign`) — the message from the phase overview's Commit section; move `.agent/phases/todo/59_response_to_docs_push/` → `.agent/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov` green in isolation (DB up, `git` on PATH — `pytest.skip` guard on absence).
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] The save → edit → push loop passes with the bare repo's file content and branch state as the assertions (no PR ever created or attempted).
- [ ] Guest + unconfigured pins pass (button absent; 403/409 as pinned).
- [ ] Regression suites pass in isolation.
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.