---
phase: 24-llm-generated-description
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [internal/agent/agent.go, internal/agent/prompts.go, internal/agent/describe.go, internal/agent/describe_test.go, internal/server/handlers_agent.go, internal/server/handlers_describe_test.go, internal/server/router.go, internal/okf/charset.go, web/src/api/client.ts, web/src/components/DescriptionGenerate.tsx, web/src/components/DescriptionGenerate.css, web/src/components/DescriptionGenerate.test.tsx, web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx]
autonomous: true
requirements: [ED-01a, ED-01b]

must_haves:
  truths:
    - "The manual Description <input> is removed from PageEditor; the description frontmatter key is preserved (okf.Repair unchanged)"
    - "agent.GenerateDescription is single-shot, read-only, returns (text, baseRev); never auto-runs"
    - "Generated text is ~1 plain-text sentence, soft-capped ~160 chars, shown for review, editable + regenerable before apply"
    - "POST /agent/generate-description is any-authed read (never writes); POST /agent/apply-description is editor + CSRF"
    - "Approve writes the description via byte-stable okf.SetField through a revision-checked save (409 on a moved revision)"
    - "The approved text is re-validated server-side before write (trim, single-line, charset, length cap); client list never trusted"
    - "Apply is autosave-safe: approve description -> keep typing body -> next autosave does NOT 409 and does NOT revert the description"
    - "Reconcile merges the description into local frontmatter BEFORE getPage (apply is authoritative; transient getPage failure cannot strand/revert it)"
    - "No description is ever written without explicit approval; no generate on mount/save/create"
    - "A targeted apply-vs-in-flight-autosave interleaving test exists (the test Phase 23 lacked); own-apply conflict copy is not misattributed"
---

<objective>
Remove the manual Description field and replace it with an on-demand agent generator: generate
(read-only) → review/edit/regenerate → approve (server re-validates, writes byte-stably via
okf.SetField, revision-checked) → reconcile (autosave-safe, merge-first). Backend + frontend.
Never auto-runs; reuses the Phase-23 apply→reconcile pattern and closes its two carried caveats.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §ED-01a §ED-01b
@.planning/phases/24-llm-generated-description/24-CONTEXT.md
@.planning/phases/23-llm-tag-suggestions/23-VERIFICATION.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: agent.GenerateDescription + prompt + ValidateDescription</name>
  <files>internal/agent/describe.go, internal/agent/prompts.go, internal/agent/agent.go, internal/agent/describe_test.go, internal/okf/charset.go</files>
  <read_first>
    - internal/agent/agent.go — SummarizePage (369), generateOnce (346), error sentinels (ErrAgentDisabled etc.)
    - internal/agent/suggesttags.go — SuggestTags (266): the Get-page → capture Revision → generate → validate-with-retry shape
    - internal/agent/prompts.go — buildSummarizeMessages / buildSuggestTagsMessages (the message-builder pattern)
  </read_first>
  <behavior>
    `GenerateDescription(ctx, path) (text string, baseRev string, err error)` in describe.go:
    Get the page (404 passthrough), capture `baseRev = Pages.Revision(path)` (echoed to apply for the
    409 floor), `truncateForBudget(pg.Body, maxSingleShotInput)`, build a describe prompt, `generateOnce`
    with a small token cap + low temperature, then validate+retry (fail-closed like the other modes).
    `buildDescribeMessages(body, attempt)` in prompts.go: instruct ONE plain-text meta sentence describing
    the page — no markdown, no surrounding quotes, no newlines, ≤ ~160 chars. `ValidateDescription(text)`
    (in okf/charset.go beside ValidateTagCharset, or agent): trim, reject NUL/control chars, collapse
    internal whitespace, enforce single-line + the length cap; return the cleaned text or an error.
  </behavior>
  <verify>
    `describe_test.go` (mirror suggesttags_test with a fake ChatModel): a normal body yields a trimmed
    single-line ≤cap description + a non-empty baseRev; an over-long/markup/empty model reply is
    rejected→retried→fails closed; agent-disabled returns ErrAgentDisabled. `ValidateDescription`
    rejects newlines/control chars/over-cap and accepts a clean sentence.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: generate-description + apply-description handlers + routes</name>
  <files>internal/server/handlers_agent.go, internal/server/router.go, internal/server/handlers_describe_test.go</files>
  <read_first>
    - internal/server/handlers_agent.go — handleSuggestTags (650; read-mode template), handleApplyTags (715; editor+CSRF, re-validate, setTagsFrontmatter, pages.Save base_revision, 204, 409 floor), auditAgentMode
    - internal/server/router.go — agent route block (≈191-198)
  </read_first>
  <behavior>
    `handleGenerateDescription` (mirror handleSuggestTags): POST body `{ page_path }`; any-authed; calls
    `agent.GenerateDescription`; returns `{ page_path, description, base_revision }`; never writes; 404 on
    missing page; fail-closed on agent-off/error. `handleApplyDescription` (mirror handleApplyTags): POST
    `{ page_path, description, base_revision }`; **editor + CSRF**; re-validate via `ValidateDescription`
    server-side (422 on invalid — never trust the client); fetch page; produce the new frontmatter region
    with ONLY `description` changed via `okf.SetField` (a `setDescriptionFrontmatter(frontmatter, body,
    text)` helper mirroring `setTagsFrontmatter`); `pages.Save(... base_revision ...)` (same single-writer
    commit + 409 floor); audit the approval (role only, never the text); return 204. Register both routes
    in router.go (generate alongside suggest-tags; apply alongside apply-tags, editor+CSRF group).
  </behavior>
  <verify>
    `handlers_describe_test.go` (mirror handlers_tags_test): generate returns 200 + base_revision; apply
    as editor writes the description byte-stably (other keys + body unchanged) and returns 204; apply as
    reader → 403; apply with a stale base_revision → 409 ("changed since…"); apply with an invalid
    description (newline/over-cap) → 422; missing CSRF → rejected. Assert the body is untouched by apply.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: client generateDescription + applyDescription</name>
  <files>web/src/api/client.ts</files>
  <read_first>
    - web/src/api/client.ts — suggestTags (≈963), applyTags (≈977, void/204), the `mutate` helper, getPage
  </read_first>
  <behavior>
    `generateDescription(pagePath): Promise<{ page_path: string; description: string; base_revision: string }>`
    (POST /api/v1/agent/generate-description, mirror suggestTags). `applyDescription(payload: { page_path:
    string; description: string; base_revision: string }): Promise<void>` (POST /api/v1/agent/apply-description,
    mirror applyTags, 204). CSRF handled by `mutate` as for applyTags.
  </behavior>
  <verify>Covered by the component + handler tests; ensure types compile under tsconfig.app + test.</verify>
</task>

<task type="implement" tdd="true">
  <name>Task 4: DescriptionGenerate component (generate → review/edit/regenerate → approve)</name>
  <files>web/src/components/DescriptionGenerate.tsx, web/src/components/DescriptionGenerate.css, web/src/components/DescriptionGenerate.test.tsx</files>
  <read_first>
    - web/src/components/TagSuggest.tsx — the generate→review→apply structure, editor-gate (me/role), stale/409 + apply-error handling, onApplied handoff
  </read_first>
  <behavior>
    `DescriptionGenerate({ pagePath, onApplied }: { pagePath: string; onApplied?: (text: string) => void })`.
    Editor-gated (reader sees nothing). A "Generate description" button → `generateDescription` → opens a
    review surface showing the draft in an EDITABLE textarea (the user may edit it) with "Regenerate"
    (re-calls generate, new base_revision) and "Apply"/"Cancel". Apply → `applyDescription({ page_path,
    description: editedText, base_revision })`; on 204 → `onApplied?.(editedText)` + close; on 409 → stale
    state ("changed since… regenerate"); other error → recoverable error line. NEVER calls generate on
    mount. The textarea is the review surface (editable + regenerable) ED-01b requires.
  </behavior>
  <verify>
    `DescriptionGenerate.test.tsx`: mounting issues NO generate call; clicking Generate shows the editable
    draft; editing the textarea then Apply sends the EDITED text; Regenerate re-fetches; a reader role
    renders nothing; a 409 shows the stale/regenerate state and does not call onApplied.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 5: PageEditor — remove Description input, wire DescriptionGenerate, reconcile (autosave-safe) + carried caveats</name>
  <files>web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx</files>
  <read_first>
    - web/src/routes/PageEditor.tsx — the Description <input> block (632-643) to delete; reconcileAppliedTags (Phase 23) — the merge-first pattern to parallel; refs 121-135; setField/readField import (16)
    - .planning/phases/23-llm-tag-suggestions/23-VERIFICATION.md — the two carried caveats (D-08)
  </read_first>
  <behavior>
    Delete the `<input id="fm-description">` + label (632-643) — ED-01a. Render `<DescriptionGenerate
    pagePath={path} onApplied={reconcileAppliedDescription} />` near the title/tags area. Implement
    `reconcileAppliedDescription(text)` MERGE-FIRST (mirror reconcileAppliedTags; do NOT reintroduce the
    getPage-before-merge data-loss bug):
    ```ts
    const reconcileAppliedDescription = useCallback(async (text: string) => {
      const merged = setField(frontmatterRef.current, "description", text); // merge FIRST (authoritative)
      setFrontmatter(merged); frontmatterRef.current = merged;
      let fresh; try { fresh = await getPage(path); }
      catch { setSaveError("We couldn't refresh just now. Your changes are kept here."); return; }
      baseRevision.current = fresh.revision; lastSavedFrontmatter.current = fresh.frontmatter;
      if (merged !== fresh.frontmatter) scheduleAutosave();
    }, [path]);
    ```
    Generalize the Phase-23 reconcile helper if it factors cleanly (apply + merge-fn + getPage advance).
    **Carried caveats (D-08):** (a) ensure the own-apply 409 path does not show a misattributed "changed
    somewhere else" message (adjust copy/branch for the self-initiated apply if cheap); (b) ADD the
    targeted apply-vs-in-flight-autosave interleaving test Phase 23 lacked.
  </behavior>
  <verify>
    `PageEditor.test.tsx`: (a) no Description input renders; an existing `description` in frontmatter is
    preserved after mount + a body autosave (not blanked). (b) mount issues no generate/apply call
    (never-auto). (c) generate→edit→approve: applyDescription called with the edited text; the description
    is merged into local frontmatter; a subsequent body autosave uses the post-apply revision and does NOT
    409 / does NOT revert the description. (d) the in-flight-autosave interleaving test: an apply landing
    while a body save is in flight stays data-safe (description preserved through the conflict resolution).
  </verify>
</task>

</tasks>

<success_criteria>
1. The editor has no Description input; existing descriptions are untouched; every page still has a
   `description` frontmatter key (okf.Repair unchanged).
2. "Generate description" returns a ~1-sentence draft for review; editable + regenerable before apply;
   never auto-runs (no generate on mount/save/create).
3. Approve writes `description` via byte-stable `okf.SetField` through the revision-checked
   apply-description endpoint (editor + CSRF; 409 on a moved revision; 422 on a server-invalid text).
4. Autosave-safe: approve description → keep typing the body → the next autosave neither 409s into the
   conflict dialog nor reverts the just-applied description (merge-first reconcile + advanced
   baseRevision). The interleaving (apply during an in-flight save) is data-safe and pinned by a test.
5. No description is ever written without explicit approval. Backend: `go build ./... && go test ./...`
   green. Frontend (from web/): `npx vitest run` green; `npm run build` clean.
</success_criteria>
