kanban-dev-webapp/docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md

33 KiB

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:

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
npm test

Expected: FAIL — Failed to resolve import "@/lib/ai/narrative".

  • Step 3: Write the implementation

src/lib/ai/narrative.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
npm test

Expected: PASS — 17 tests in narrative.test.ts.

  • Step 5: Commit
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:

async function ensureCardNarrativeFields(pb: PocketBase): Promise<void> {
  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:

  /** 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
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
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:

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<NarrateResult> {
  if (!(await aiConfigured())) {
    return { considered: 0, written: 0, skipped: true };
  }

  const pb = await getAdminPb();

  const cards = await pb.collection("cards").getFullList<CardRecord>({
    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<CommitRecord>({
      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:

    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
npm run build

Expected: build succeeds.

  • Step 4: Commit
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:

"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 (
    <section className="space-y-3">
      <div className="flex items-center gap-3">
        <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
          Work done
        </h2>
        <span className="tabular text-xs text-text-muted">
          {commits.length} {commits.length === 1 ? "change" : "changes"}
        </span>
        <button
          type="button"
          onClick={() => setShowRaw((value) => !value)}
          aria-pressed={showRaw}
          className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-text-muted hover:text-text-secondary"
        >
          <Code2 size={12} strokeWidth={2} />
          {showRaw ? "Hide technical detail" : "Show technical detail"}
        </button>
      </div>

      <ol className="space-y-2.5">
        {commits.map((commit) => (
          <li key={commit.id} className="flex gap-2.5">
            <span
              aria-hidden
              className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-border-strong"
            />
            <div className="min-w-0 flex-1">
              <p className="text-sm text-text-secondary">
                {/* Fall back to the raw subject — never render nothing. */}
                {commit.summary || commit.subject}
              </p>
              <p className="mt-0.5 text-[11px] text-text-muted">
                {relativeTime(commit.authored_at)}
              </p>

              {showRaw && (
                <p className="mt-1.5 rounded bg-surface px-2 py-1.5 font-mono text-[11px] text-text-muted">
                  {commit.url ? (
                    <a
                      href={commit.url}
                      target="_blank"
                      rel="noreferrer"
                      className="hover:text-brand"
                    >
                      {commit.short_sha}
                    </a>
                  ) : (
                    commit.short_sha
                  )}{" "}
                  {commit.subject}
                </p>
              )}
            </div>
          </li>
        ))}
      </ol>
    </section>
  );
}
  • Step 2: Write the narrative component

src/components/card/card-narrative.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 (
    <section className="space-y-2 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
      <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
        Progress
      </h2>

      <div className="space-y-2 text-sm text-text-secondary">
        {narrative
          .split(/\n{2,}/)
          .map((paragraph) => paragraph.trim())
          .filter(Boolean)
          .map((paragraph, index) => (
            <p key={index} className="whitespace-pre-wrap">
              {paragraph}
            </p>
          ))}
      </div>

      <p className="text-[11px] text-text-muted">
        Written automatically from the change history for this item.
      </p>
    </section>
  );
}
  • 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:

    const commits = await pb.collection("commits").getFullList<CommitRecord>({
      filter: `card = "${cardId}" && is_merge = false`,
      sort: "authored_at",
    });

Then in the page, between CardHeader and SubtaskList:

          <CardNarrative narrative={detail.card.narrative ?? ""} />
          <CommitHistory commits={detail.commits} />
  • Step 4: Verify the build
npm run build

Expected: build succeeds.

  • Step 5: Commit
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:

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 (
    <section className="space-y-3">
      <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
        Recent work
      </h2>

      <ol className="space-y-2">
        {commits.map((commit) => (
          <li
            key={commit.id}
            className="flex items-baseline gap-3 border-b border-border-subtle pb-2 last:border-0"
          >
            <p className="min-w-0 flex-1 text-sm text-text-secondary">
              {commit.summary || commit.subject}
            </p>
            <span className="shrink-0 text-[11px] text-text-muted">
              {relativeTime(commit.authored_at)}
            </span>
          </li>
        ))}
      </ol>
    </section>
  );
}
  • Step 2: Render it on the dashboard

In the dashboard page, fetch the most recent non-merge commits and render <RecentActivity> between MonthBars and the two BarList breakdowns:

  const recent = await pb.collection("commits").getList<CommitRecord>(1, 12, {
    filter: `project = "${board.project.id}" && is_merge = false`,
    sort: "-authored_at",
  });
              <RecentActivity commits={recent.items} />
  • Step 3: Verify the build
npm run build

Expected: build succeeds.

  • Step 4: Commit
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:

"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:

        <button
          type="button"
          disabled={pending}
          onClick={() =>
            startTransition(async () => {
              setMessage("Writing narratives…");
              const result = await narrateProjectAction({ projectId });
              setMessage(result.message);
            })
          }
          className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
        >
          Write card narratives
        </button>
  • Step 3: Verify the build
npm run build

Expected: build succeeds.

  • Step 4: Commit
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:

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:

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
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
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
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
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.