From f3e4b30d467fcd1ee2d1a0661af6852848a10c99 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 15:52:12 +0200 Subject: [PATCH] feat(ai): settings storage with a browser-safe projection --- ...6-09-16-phase-3c-narratives-and-display.md | 1105 +++++++++++++++++ src/lib/ai/settings.ts | 114 ++ 2 files changed, 1219 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md create mode 100644 src/lib/ai/settings.ts diff --git a/docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md b/docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md new file mode 100644 index 0000000..e3f1c55 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md @@ -0,0 +1,1105 @@ +# Phase 3c — Card Narratives and Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Write a progress narrative for each card from its linked commits, and put the humanised history in front of the client — on the card, and in the dashboard's recent activity — with the raw commit always one click away. + +**Architecture:** A pure narrative-prompt builder and a pure staleness check, both tested without a database. Narratives are keyed on the *set* of linked commit SHAs, so adding a commit marks the narrative stale while re-running the linker over unchanged commits does not. Every display path falls back to the raw commit subject when no summary exists. + +**Tech Stack:** Next.js 16 Server Actions · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-16-phase-3-ai-humanisation-design.md` §5.3, §9 + +**Prerequisite:** Plan 3b complete — commits have one-liners. + +--- + +## This is the plan a client actually reads + +Everything before this stored data. This one puts generated text in front of the +person paying for the work, which changes what "wrong" costs. + +Three rules run through it: + +**A narrative never says a card is finished.** Completion is a column on the +board, set by the developer. Commits cannot establish it, and a narrative +claiming it contradicts the board the client is looking at in the same window. + +**The raw commit is always reachable.** One toggle, showing the real subject and +short SHA linked to Forgejo. The humanised text is a translation, not a +replacement, and a client who wants to check it must be able to. + +**No summary means the raw subject, never a gap.** Every rendering path falls +back. A card that shows nothing because the provider was down looks like no work +happened. + +--- + +## File structure + +``` +src/ +├── lib/ +│ └── ai/ +│ ├── narrative.ts # prompt + staleness +│ └── narrative.test.ts +├── app/ +│ └── actions/ +│ └── narrative.ts +└── components/ + └── card/ + ├── commit-history.tsx # humanised list + raw toggle + └── card-narrative.tsx +``` + +--- + +## Task 1: Narrative prompt and staleness + +Pure, test-first. + +**Files:** +- Create: `src/lib/ai/narrative.ts` +- Test: `src/lib/ai/narrative.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/ai/narrative.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + buildNarrativePrompt, + narrativeIsStale, + shaFingerprint, + type NarrativeCard, + type NarrativeCommit, +} from "@/lib/ai/narrative"; + +const card: NarrativeCard = { + title: "Add a POS system", + description: "Card payments at the counter.", +}; + +function commit(sha: string, subject: string, date = "2026-06-01"): NarrativeCommit { + return { sha, subject, summary: "", authoredAt: `${date}T10:00:00Z` }; +} + +describe("shaFingerprint", () => { + it("is stable regardless of order", () => { + expect(shaFingerprint(["b", "a", "c"])).toBe(shaFingerprint(["a", "c", "b"])); + }); + + it("changes when a sha is added or removed", () => { + expect(shaFingerprint(["a", "b"])).not.toBe(shaFingerprint(["a", "b", "c"])); + expect(shaFingerprint(["a", "b"])).not.toBe(shaFingerprint(["a"])); + }); + + it("handles an empty set", () => { + expect(shaFingerprint([])).toBe(""); + }); +}); + +describe("narrativeIsStale", () => { + it("is stale when there is no narrative yet", () => { + expect(narrativeIsStale({ narrative: "", version: 1, shas: "" }, ["a"], 1)).toBe(true); + }); + + it("is not stale when the commit set and version match", () => { + const fp = shaFingerprint(["a", "b"]); + expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["b", "a"], 1)).toBe(false); + }); + + it("is stale when a commit is added", () => { + const fp = shaFingerprint(["a"]); + expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["a", "b"], 1)).toBe(true); + }); + + it("is stale when the prompt version moves on", () => { + // The text was written under rules that no longer exist. + const fp = shaFingerprint(["a"]); + expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["a"], 2)).toBe(true); + }); + + it("is NOT stale merely because the linker ran again", () => { + // Re-linking happens on every push. If an unchanged commit set counted as + // stale, every push would regenerate every narrative on the project and + // rewrite text the client has already read. + const fp = shaFingerprint(["a", "b", "c"]); + const stored = { narrative: "text", version: 1, shas: fp }; + expect(narrativeIsStale(stored, ["c", "b", "a"], 1)).toBe(false); + }); + + it("is not stale for a card with no commits and no narrative", () => { + // Nothing to write about. Generating anything here would be invention. + expect(narrativeIsStale({ narrative: "", version: 1, shas: "" }, [], 1)).toBe(false); + }); +}); + +describe("buildNarrativePrompt", () => { + const commits = [ + commit("aaa", "feat(pos): card reader integration", "2026-06-01"), + commit("bbb", "fix(pos): handle declined payments", "2026-06-05"), + ]; + + it("includes the card title and description", () => { + const prompt = buildNarrativePrompt(card, commits); + expect(prompt).toContain("Add a POS system"); + expect(prompt).toContain("Card payments at the counter."); + }); + + it("lists commits oldest first", () => { + const prompt = buildNarrativePrompt(card, [commits[1], commits[0]]); + expect(prompt.indexOf("card reader")).toBeLessThan(prompt.indexOf("declined payments")); + }); + + it("prefers an existing one-liner over the raw subject", () => { + const withSummary: NarrativeCommit = { + ...commits[0], + summary: "Connected the card reader.", + }; + const prompt = buildNarrativePrompt(card, [withSummary]); + expect(prompt).toContain("Connected the card reader."); + expect(prompt).not.toContain("feat(pos): card reader integration"); + }); + + it("falls back to the raw subject when there is no one-liner", () => { + const prompt = buildNarrativePrompt(card, commits); + expect(prompt).toContain("card reader integration"); + }); + + it("instructs the model not to claim completion", () => { + const prompt = buildNarrativePrompt(card, commits).toLowerCase(); + expect(prompt).toContain("complete"); + }); + + it("includes dates so the narrative can describe a sequence", () => { + const prompt = buildNarrativePrompt(card, commits); + expect(prompt).toContain("2026-06-01"); + }); + + it("flattens newlines so one commit is one line", () => { + const prompt = buildNarrativePrompt(card, [commit("ccc", "one\ntwo")]); + expect(prompt).toContain("one two"); + }); + + it("caps a very long commit list", () => { + const many = Array.from({ length: 500 }, (_, i) => commit(`s${i}`, `subject ${i}`)); + const prompt = buildNarrativePrompt(card, many); + // A card with 500 commits must not blow the context window or the bill. + expect(prompt.length).toBeLessThan(40_000); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/ai/narrative"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/ai/narrative.ts`: + +```ts +/** + * Card progress narratives. + * + * Keyed on the SET of linked commit SHAs rather than a timestamp, because + * re-linking runs on every push. A time-based check would regenerate every + * narrative on every push — rewriting text a client has already read, and + * paying for it each time. + */ + +/** Most recent commits kept when a card has more than this. */ +const MAX_COMMITS_IN_PROMPT = 120; + +export interface NarrativeCard { + title: string; + description: string; +} + +export interface NarrativeCommit { + sha: string; + subject: string; + /** The Plan 3b one-liner, or "" when not generated. */ + summary: string; + authoredAt: string; +} + +export interface StoredNarrative { + narrative: string; + version: number; + /** The fingerprint the stored text was written from. */ + shas: string; +} + +/** Order-independent identity for a set of SHAs. */ +export function shaFingerprint(shas: string[]): string { + if (shas.length === 0) return ""; + return [...shas].sort().join(","); +} + +export function narrativeIsStale( + stored: StoredNarrative, + currentShas: string[], + promptVersion: number +): boolean { + // Nothing to write about. Generating here would be invention. + if (currentShas.length === 0 && !stored.narrative) return false; + + if (!stored.narrative) return true; + if (stored.version !== promptVersion) return true; + + return stored.shas !== shaFingerprint(currentShas); +} + +function flatten(value: string): string { + return (value ?? "").replace(/\s+/g, " ").trim(); +} + +export function buildNarrativePrompt( + card: NarrativeCard, + commits: NarrativeCommit[] +): string { + const ordered = [...commits].sort((a, b) => + a.authoredAt.localeCompare(b.authoredAt) + ); + + // Keep the most recent when a card is enormous: the oldest commits on a + // long-running card tell the client least about where it stands now. + const kept = + ordered.length > MAX_COMMITS_IN_PROMPT + ? ordered.slice(ordered.length - MAX_COMMITS_IN_PROMPT) + : ordered; + + const lines = kept.map((commit) => { + const date = commit.authoredAt.replace(" ", "T").slice(0, 10); + // The one-liner is already in the client's language; prefer it. + const text = flatten(commit.summary || commit.subject); + return `- ${date}: ${text}`; + }); + + const omitted = ordered.length - kept.length; + const note = omitted > 0 ? `\n(${omitted} earlier entries omitted.)` : ""; + + return `Write a short progress update for the client about this piece of work. + +Work item: ${flatten(card.title)} +${card.description ? `What was asked for: ${flatten(card.description)}` : ""} + +What has happened, oldest first: +${lines.join("\n")}${note} + +Write two or three short paragraphs covering what was built, anything that changed along the way, and where the work stands according to these entries. + +Do not say the work is complete, finished or done — whether it is finished is recorded separately on the project board, not in this history. Do not mention dates for future work. Do not invent anything that is not above. + +Reply with the paragraphs only.`; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 17 tests in `narrative.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/ai/narrative.ts src/lib/ai/narrative.test.ts +git commit -m "feat(ai): narrative prompt and set-based staleness" +``` + +--- + +## Task 2: Schema + +**Files:** +- Modify: `scripts/setup-remote.ts` +- Modify: `src/lib/pb/types.ts` + +- [ ] **Step 1: Add the fields** + +In `scripts/setup-remote.ts`, beside the other `ensure*` helpers: + +```ts +async function ensureCardNarrativeFields(pb: PocketBase): Promise { + const cards = await pb.collections.getOne("cards"); + const have = new Set(cards.fields.map((f: { name: string }) => f.name)); + + const additions = [ + { name: "narrative", type: "text", required: false, max: 8000 }, + { name: "narrative_version", type: "number", required: false, onlyInt: true }, + { name: "narrative_shas", type: "text", required: false, max: 20000 }, + ]; + + const fields = [...(cards.fields as unknown[])]; + let changed = false; + + for (const field of additions) { + if (!have.has(field.name)) { + fields.push({ ...field, presentable: false, hidden: false, system: false }); + changed = true; + } + } + + if (changed) { + await pb.collections.update(cards.id, { fields }); + console.log(" + cards fields: narrative, narrative_version, narrative_shas"); + } else { + console.log(" = card narrative fields already present"); + } +} +``` + +Call it after `ensureCardNumbers(pb)`. + +- [ ] **Step 2: Extend the record type** + +In `src/lib/pb/types.ts`, add to `CardRecord`: + +```ts + /** Client-facing progress narrative, generated from linked commits. */ + narrative: string; + narrative_version: number; + /** Fingerprint of the commit set the narrative was written from. */ + narrative_shas: string; +``` + +- [ ] **Step 3: Apply and verify** + +```bash +set -a && source .env.local && set +a && npm run setup-remote && npm run build +``` + +Expected: the script reports the three new fields; the build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/setup-remote.ts src/lib/pb/types.ts +git commit -m "feat(schema): card narrative fields" +``` + +--- + +## Task 3: The narrative service + +**Files:** +- Create: `src/lib/ai/narrate.ts` + +- [ ] **Step 1: Write the service** + +`src/lib/ai/narrate.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { getProvider, getSystemPrompt, AiError } from "@/lib/ai"; +import { PROMPT_VERSION } from "@/lib/ai/preamble"; +import { aiConfigured } from "@/lib/ai/settings"; +import { + buildNarrativePrompt, + narrativeIsStale, + shaFingerprint, + type NarrativeCommit, +} from "@/lib/ai/narrative"; +import type { CardRecord, CommitRecord } from "@/lib/pb/types"; + +export interface NarrateResult { + considered: number; + written: number; + skipped: boolean; + error?: string; +} + +/** + * Regenerate narratives for a project's cards where the commit set has changed. + * + * Never throws, for the same reason as summariseProject: narratives are an + * enhancement over a board that already works. + * + * One request per card, not batched. A narrative is a few paragraphs of + * connected prose about one piece of work; batching would mean the model + * writing several unrelated updates in one response, and the index-matching + * problem from Plan 3b with far more text at stake per mismatch. + */ +export async function narrateProject(projectId: string): Promise { + if (!(await aiConfigured())) { + return { considered: 0, written: 0, skipped: true }; + } + + const pb = await getAdminPb(); + + const cards = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}" && archived = false`, + fields: "id,title,description,narrative,narrative_version,narrative_shas", + }); + + let provider; + let system: string; + + try { + provider = await getProvider(); + system = await getSystemPrompt(); + } catch (error) { + return { + considered: 0, + written: 0, + skipped: true, + error: error instanceof AiError ? error.message : "AI is not configured", + }; + } + + let considered = 0; + let written = 0; + let lastError: string | undefined; + + for (const card of cards) { + const commits = await pb.collection("commits").getFullList({ + filter: `card = "${card.id}" && is_merge = false`, + sort: "authored_at", + fields: "sha,subject,summary,authored_at", + }); + + const shas = commits.map((c) => c.sha); + + const stale = narrativeIsStale( + { + narrative: card.narrative ?? "", + version: card.narrative_version ?? 0, + shas: card.narrative_shas ?? "", + }, + shas, + PROMPT_VERSION + ); + + if (!stale) continue; + + // A card with no commits gets no narrative. Writing one would be invention, + // and an empty card showing confident prose about work that has not started + // is exactly the failure this phase must not have. + if (commits.length === 0) continue; + + considered += 1; + + const items: NarrativeCommit[] = commits.map((c) => ({ + sha: c.sha, + subject: c.subject || "", + summary: c.summary || "", + authoredAt: c.authored_at, + })); + + try { + const result = await provider.complete({ + system, + user: buildNarrativePrompt( + { title: card.title, description: card.description ?? "" }, + items + ), + maxTokens: 4096, + }); + + if (!result.text) { + lastError = "The provider returned an empty narrative"; + continue; + } + + await pb.collection("cards").update(card.id, { + narrative: result.text.slice(0, 8000), + narrative_version: PROMPT_VERSION, + narrative_shas: shaFingerprint(shas), + }); + written += 1; + } catch (error) { + lastError = error instanceof AiError ? error.message : "Request failed"; + // A non-retryable failure will fail identically for every other card. + if (error instanceof AiError && !error.retryable) break; + } + } + + return { considered, written, skipped: false, error: lastError }; +} +``` + +- [ ] **Step 2: Run it after summarisation on ingest** + +In `src/lib/git/ingest.ts`, inside the existing summarisation `try` block, after +`summariseProject`: + +```ts + const { narrateProject } = await import("@/lib/ai/narrate"); + await narrateProject(project.id); +``` + +Order matters: narratives prefer the one-liners, so summarisation must run +first or the first narrative after a push is written from raw subjects. + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/ai/narrate.ts src/lib/git/ingest.ts +git commit -m "feat(ai): card narratives regenerated when the commit set changes" +``` + +--- + +## Task 4: Commit history on the card + +**Files:** +- Create: `src/components/card/commit-history.tsx` +- Create: `src/components/card/card-narrative.tsx` + +- [ ] **Step 1: Write the history component** + +`src/components/card/commit-history.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { Code2 } from "lucide-react"; + +import { relativeTime } from "@/lib/time/relative"; +import type { CommitRecord } from "@/lib/pb/types"; + +/** + * A card's commits, in the client's language. + * + * Two rules, both from the spec: + * - A commit with no summary shows its raw subject. Never a gap — a card that + * renders blank because the provider was down looks like no work happened. + * - The raw commit is always one toggle away. The humanised text is a + * translation, not a replacement, and a client who wants to check it must be + * able to. + */ +export function CommitHistory({ commits }: { commits: CommitRecord[] }) { + const [showRaw, setShowRaw] = useState(false); + + if (commits.length === 0) return null; + + return ( +
+
+

+ Work done +

+ + {commits.length} {commits.length === 1 ? "change" : "changes"} + + +
+ +
    + {commits.map((commit) => ( +
  1. + +
    +

    + {/* Fall back to the raw subject — never render nothing. */} + {commit.summary || commit.subject} +

    +

    + {relativeTime(commit.authored_at)} +

    + + {showRaw && ( +

    + {commit.url ? ( + + {commit.short_sha} + + ) : ( + commit.short_sha + )}{" "} + {commit.subject} +

    + )} +
    +
  2. + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Write the narrative component** + +`src/components/card/card-narrative.tsx`: + +```tsx +/** + * The generated progress update. + * + * Labelled as written from the commit history — the same honesty the dashboard's + * methodology panel applies to the effort figures. A client should never have to + * wonder whether a person wrote this. + */ +export function CardNarrative({ narrative }: { narrative: string }) { + if (!narrative.trim()) return null; + + return ( +
+

+ Progress +

+ +
+ {narrative + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ +

+ Written automatically from the change history for this item. +

+
+ ); +} +``` + +- [ ] **Step 3: Render both on the card page** + +In `src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx`, add the imports +and fetch the card's commits in the loader. In `src/lib/data/card.ts`, extend +`CardDetail` with `commits: CommitRecord[]` and populate it: + +```ts + const commits = await pb.collection("commits").getFullList({ + filter: `card = "${cardId}" && is_merge = false`, + sort: "authored_at", + }); +``` + +Then in the page, between `CardHeader` and `SubtaskList`: + +```tsx + + +``` + +- [ ] **Step 4: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/card src/app/p src/lib/data/card.ts +git commit -m "feat(card): progress narrative and humanised commit history" +``` + +--- + +## Task 5: Dashboard recent activity + +**Files:** +- Create: `src/components/dashboard/recent-activity.tsx` +- Modify: `src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx` + +- [ ] **Step 1: Write the component** + +`src/components/dashboard/recent-activity.tsx`: + +```tsx +import { relativeTime } from "@/lib/time/relative"; +import type { CommitRecord } from "@/lib/pb/types"; + +/** + * The most recent work, in plain English. + * + * Falls back to the raw subject per entry, so a partially summarised project + * shows a mixed list rather than a half-empty one. + */ +export function RecentActivity({ commits }: { commits: CommitRecord[] }) { + if (commits.length === 0) return null; + + return ( +
+

+ Recent work +

+ +
    + {commits.map((commit) => ( +
  1. +

    + {commit.summary || commit.subject} +

    + + {relativeTime(commit.authored_at)} + +
  2. + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Render it on the dashboard** + +In the dashboard page, fetch the most recent non-merge commits and render +`` between `MonthBars` and the two `BarList` breakdowns: + +```tsx + const recent = await pb.collection("commits").getList(1, 12, { + filter: `project = "${board.project.id}" && is_merge = false`, + sort: "-authored_at", + }); +``` + +```tsx + +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/dashboard/recent-activity.tsx src/app/p +git commit -m "feat(dashboard): recent work in plain English" +``` + +--- + +## Task 6: Owner controls + +**Files:** +- Create: `src/app/actions/narrative.ts` +- Modify: `src/components/admin/summary-panel.tsx` + +- [ ] **Step 1: Write the action** + +`src/app/actions/narrative.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { narrateProject } from "@/lib/ai/narrate"; + +/** See the security note at the top of src/app/actions/cards.ts. */ +export async function narrateProjectAction(input: { + projectId: string; +}): Promise<{ written: number; message: string }> { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + + const result = await narrateProject(input.projectId); + revalidatePath(`/admin/projects/${input.projectId}`); + + if (result.skipped) { + return { written: 0, message: result.error ?? "No AI provider is configured." }; + } + if (result.considered === 0) { + return { written: 0, message: "Every narrative is up to date." }; + } + + return { + written: result.written, + message: `${result.written} of ${result.considered} narratives written${ + result.error ? ` · ${result.error}` : "" + }`, + }; +} +``` + +- [ ] **Step 2: Add a button to the summary panel** + +In `src/components/admin/summary-panel.tsx`, import the action and add beside +the existing buttons: + +```tsx + +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/actions/narrative.ts src/components/admin/summary-panel.tsx +git commit -m "feat(admin): manual narrative generation" +``` + +--- + +## Task 7: Integration tests + +**Files:** +- Create: `tests/integration/narrative.test.ts` + +- [ ] **Step 1: Write the tests** + +`tests/integration/narrative.test.ts`: + +```ts +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let projectId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + projectId = ( + await admin.collection("projects").getFirstListItem(`slug = "${DEMO.projectSlug}"`) + ).id; +}); + +describe("a card with no commits gets no narrative", () => { + it("is left empty rather than invented", async () => { + const { narrateProject } = await import("@/lib/ai/narrate"); + await narrateProject(projectId); + + const cards = await admin.collection("cards").getFullList({ + filter: `project = "${projectId}" && archived = false`, + fields: "id,narrative", + }); + + for (const card of cards) { + const linked = ( + await admin + .collection("commits") + .getList(1, 1, { filter: `card = "${card.id}"`, fields: "id" }) + ).totalItems; + + if (linked === 0) { + expect(card.narrative ?? "").toBe(""); + } + } + }); +}); + +describe("the card page always shows something for a commit", () => { + it("falls back to the raw subject when there is no summary", async () => { + const card = await admin + .collection("cards") + .getFirstListItem(`project = "${projectId}"`); + + const marker = `raw-subject-probe-${Date.now()}`; + const commit = await admin.collection("commits").create({ + project: projectId, + sha: `narrative-probe-${Date.now()}`, + short_sha: "narprb", + message: `feat(probe): ${marker}`, + subject: `feat(probe): ${marker}`, + type: "feat", + scope: "probe", + authored_at: new Date().toISOString(), + is_merge: false, + card: card.id, + link_source: "manual", + summary: "", + }); + + const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + const response = await fetch( + `${APP_URL}/p/${DEMO.clientSlug}/${DEMO.projectSlug}/c/${card.id}`, + { headers: { cookie } } + ); + const html = await response.text(); + + expect(response.status).toBe(200); + // A gap here would look to the client like no work happened. + expect(html).toContain(marker); + + await admin.collection("commits").delete(commit.id); + }); +}); +``` + +- [ ] **Step 2: Run the integration suite** + +With `npm run dev` running: + +```bash +set -a && source .env.local && set +a && npm run test:integration +``` + +Expected: PASS — 46 tests (44 after Plan 3b, plus 2 here). + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/narrative.test.ts +git commit -m "test(narrative): no invention, and raw-subject fallback" +``` + +--- + +## Task 8: Full verification + +**This task is work, not a summary. Run every command and act on what it prints.** +This is the first plan whose output a client reads, so Step 3 is the important one. + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test:all +``` + +Expected: PASS. Unit tests total **248** — the 231 after Plan 3b, plus 17 in +`narrative.test.ts`. Integration tests total 46. + +- [ ] **Step 2: Verify the build and the colour check** + +```bash +npm run build +grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app +``` + +Expected: build succeeds; the grep prints nothing. + +- [ ] **Step 3: Read a narrative as the client would** + +Set a card's `scope_rule` to a scope with real history — `quotes` or `stock` — +run linking, then press **Write card narratives**. Open that card signed in as +the client manager. + +Read what it says, and check specifically: + +- **Does it claim the work is finished?** It must not, whatever column the card + is in. This is the failure most likely to contradict the board in the same + window. +- **Does it mention a date for future work, or an estimate?** There must be none. +- **Does it describe removals as removals?** WFDASH-WEBAPP's history has several. +- **Is there anything in it that is not in the commits?** Invented detail is the + hardest failure to spot, because it reads exactly as well as the true parts. + +If any of these fail, fix the preamble or the narrative prompt and regenerate. +Do not ship a narrative you would not send to a client yourself. + +- [ ] **Step 4: Check the raw toggle** + +On the same card, press **Show technical detail**. + +Expected: each entry gains its short SHA and real subject, with the SHA linking +to Forgejo. + +- [ ] **Step 5: Check the failure path** + +Clear the API key and reload the card and the dashboard. + +Expected: both render fully. The commit history shows raw subjects, the +narrative section is absent rather than empty-framed, and nothing errors. + +- [ ] **Step 6: Check both themes** + +Open the card and the dashboard in light and dark. Narrative paragraphs, the +history timeline dots, and the raw-detail blocks must all be legible in both. + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A +git commit -m "fix: issues found during Phase 3c verification" +``` + +--- + +## Definition of done + +1. `npm run test:all` passes: 248 unit, 46 integration. +2. `npm run build` succeeds; the colour check prints nothing. +3. A card with linked commits shows a narrative; a card without shows none. +4. No narrative claims completion, states a future date, or contains detail + absent from the commits. +5. The raw subject and SHA are reachable from every entry, linked to Forgejo. +6. With no key configured, the card and dashboard render using raw subjects. +7. Re-running linking over an unchanged commit set regenerates nothing. + +## Deliberately not in this plan + +- **Automatic regeneration when `PROMPT_VERSION` changes.** Narratives are + regenerated when their commit set changes; a prompt-rule change marks them + stale and the owner presses the button. Silently rewriting text a client has + already read is worse than showing slightly older text. +- **A narrative for archived cards.** Excluded from the query. +- **Per-card effort hours.** Spec §5.4 requires the caveat that sessions + touching several cards contribute padding to each, so per-card hours sum to + more than the project total. That needs its own explanation next to the + number, and belongs with a plan that has room to do it properly. +- **Editing a narrative by hand.** It regenerates from commits; a hand-edit + would be silently overwritten on the next push. If the owner wants to write to + the client, that is a comment. +- **The client intake wizard** — Phase 4, which is where this phase's provider + layer gets its second consumer. diff --git a/src/lib/ai/settings.ts b/src/lib/ai/settings.ts new file mode 100644 index 0000000..306bf39 --- /dev/null +++ b/src/lib/ai/settings.ts @@ -0,0 +1,114 @@ +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import type { ProviderName } from "@/lib/ai/types"; + +/** + * Reads and writes the single `settings` record created in Phase 1. + * + * Superuser-only, always. `ai_api_key` is a live credential and every rule on + * that collection is null for exactly that reason — no browser, the owner's + * included, may fetch it. Nothing here may be called from a client component. + */ + +export interface AiSettings { + provider: ProviderName; + apiKey: string; + model: string; + systemPromptExtra: string; +} + +/** Safe to send to a browser: presence of a key, never its value. */ +export interface AiSettingsView { + provider: ProviderName; + hasApiKey: boolean; + model: string; + systemPromptExtra: string; +} + +export const DEFAULT_MODELS: Record = { + // Verified against the bundled claude-api skill, not recalled. The whole + // 779-commit backfill costs well under a dollar on this model, so there is no + // cost argument for a weaker default. + anthropic: "claude-opus-5", + minimax: "MiniMax-M2", + openai: "gpt-4o-mini", +}; + +async function settingsRecord() { + const pb = await getAdminPb(); + const existing = await pb + .collection("settings") + .getFirstListItem("id != ''") + .catch(() => null); + return { pb, existing }; +} + +export async function loadAiSettings(): Promise { + const { existing } = await settingsRecord(); + if (!existing) return null; + + const provider = (existing.ai_provider || "anthropic") as ProviderName; + + return { + provider, + apiKey: existing.ai_api_key || "", + model: existing.ai_model || DEFAULT_MODELS[provider], + systemPromptExtra: existing.ai_system_prompt_extra || "", + }; +} + +/** The browser-safe projection. Never returns the key itself. */ +export async function loadAiSettingsView(): Promise { + const settings = await loadAiSettings(); + if (!settings) { + return { + provider: "anthropic", + hasApiKey: false, + model: DEFAULT_MODELS.anthropic, + systemPromptExtra: "", + }; + } + + return { + provider: settings.provider, + hasApiKey: Boolean(settings.apiKey), + model: settings.model, + systemPromptExtra: settings.systemPromptExtra, + }; +} + +/** + * Write settings, creating the record on first save. + * + * `apiKey` is only written when a non-empty value is supplied. The settings + * form submits an empty key field when the owner is editing something else, and + * treating that as "clear the key" would silently disable the whole feature. + */ +export async function saveAiSettings(patch: { + provider?: ProviderName; + apiKey?: string; + model?: string; + systemPromptExtra?: string; +}): Promise { + const { pb, existing } = await settingsRecord(); + + const data: Record = {}; + if (patch.provider !== undefined) data.ai_provider = patch.provider; + if (patch.model !== undefined) data.ai_model = patch.model.trim(); + if (patch.systemPromptExtra !== undefined) { + data.ai_system_prompt_extra = patch.systemPromptExtra; + } + if (patch.apiKey) data.ai_api_key = patch.apiKey.trim(); + + if (existing) { + await pb.collection("settings").update(existing.id, data); + } else { + await pb.collection("settings").create(data); + } +} + +export async function aiConfigured(): Promise { + const settings = await loadAiSettings(); + return Boolean(settings?.apiKey); +} \ No newline at end of file