diff --git a/src/lib/ai/narrative.test.ts b/src/lib/ai/narrative.test.ts new file mode 100644 index 0000000..6f42c0a --- /dev/null +++ b/src/lib/ai/narrative.test.ts @@ -0,0 +1,123 @@ +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); + }); +}); diff --git a/src/lib/ai/narrative.ts b/src/lib/ai/narrative.ts new file mode 100644 index 0000000..5ed5762 --- /dev/null +++ b/src/lib/ai/narrative.ts @@ -0,0 +1,95 @@ +/** + * 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.`; +}