kanban-dev-webapp/docs/superpowers/plans/2026-09-16-phase-2b-commit-card-linking.md
Dave 0fd2cef2cf docs(plan): Phase 2b and 2c for commit linking, metrics and the dashboard
2b closes a gap between the spec and the schema: the spec's [#42] explicit
link had nothing to reference, because cards only had PocketBase ids. Adds
per-project card numbers, and sharpens two rules the spec left loose — a
scope rule is bounded by start_date OR created, and ties go to the card
with the latest bound rather than the most recently edited one.

2c builds the dashboard. The South African holiday table is verified
against the Hermes build log: 24 May to 8 Sep 2026 gives 108 calendar days
and 75 workdays, both exact, and that is now a regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:52:42 +02:00

49 KiB

Phase 2b — Commit-to-Card Linking 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: Attach each ingested commit to the card whose work it represents — automatically by conventional-commit scope, explicitly by a reference in the message, and by hand for whatever is left over.

Architecture: Two pure, exhaustively tested modules — one that extracts an explicit card reference from a message, one that resolves a commit to a card given the project's candidate cards — plus a database layer that applies them. Re-linking is idempotent and re-runnable, and never overwrites a link a human made by hand.

Tech Stack: Next.js 16 Server Actions · PocketBase 0.30 · vitest

Spec: docs/superpowers/specs/2026-09-16-phase-2-commit-ingestion-and-dashboard-design.md §7

Prerequisite: Plan 2a complete — commits are being ingested, with type and scope parsed.


A gap in the spec, and how this plan closes it

The spec specifies an explicit link written as [#42] in a commit message. Cards have no number. Phase 1 gave them PocketBase ids like 2e8izsch98bbc6l, and nobody is going to type that into a commit message.

So Task 1 adds a per-project card number, backfills the existing cards, and assigns one on both creation paths. Without it the first branch of the precedence chain cannot exist.

This is worth noticing as a class of problem: the spec was written before the schema, the schema was built without the spec's reference syntax in view, and neither document was wrong on its own.


The linking rules, stated precisely

The spec gives the precedence chain and two constraints. Two of them need sharpening before they can be implemented, and both sharpenings are decisions:

Precedence — first match wins

  1. [#42] in the message → card number 42 in this project.
  2. The card's scope_rule matching the commit's parsed scope.
  3. No match → the commit stays unlinked and appears in the Unassigned tray.

A commit linked by hand from the tray is link_source: "manual" and sits outside this chain entirely — see "Re-linking" below.

Sharpening 1: what bounds a scope rule

The spec says a scope rule claims commits "from the card's start_date forward". But start_date is only set by the accept flow in Plan 1c, so a card the owner created directly has none, and a literal reading means the owner's own cards — the overwhelming majority — could never match anything.

The bound is start_date || created. Every card has created. A card that was never formally started is bounded by the moment it came into existence, which is the same intent.

Sharpening 2: which card wins when several claim a scope

The spec says "the most recently active card wins". updated is a poor basis: it changes when anyone edits a title, so linking would silently rearrange itself because someone fixed a typo.

The winner is the eligible card with the latest bound. Among cards whose start_date || created is at or before the commit's authored_at, pick the one whose bound is latest.

This is deterministic, depends only on values that do not drift, and produces the behaviour actually wanted: a "Stock pricing v2" card started in June takes July's feat(stock) commits, while the January card keeps January's.

Re-linking

Re-linking a project is idempotent and safe to run repeatedly. It recomputes every commit whose link_source is none, scope or explicit — all three are derived from data and should follow it.

It never touches manual. A link the owner made by hand from the tray is a human decision that outranks every rule here; silently overwriting it on the next push would make the tray pointless.


File structure

src/
├── lib/
│   └── linking/
│       ├── reference.ts             # "[#42]" -> 42
│       ├── reference.test.ts
│       ├── resolve.ts               # commit + candidates -> card
│       └── resolve.test.ts
├── app/
│   ├── actions/
│   │   └── linking.ts               # relink project, link/unlink by hand
│   └── p/[clientSlug]/[projectSlug]/
│       └── unassigned/page.tsx      # the tray
└── components/
    └── linking/
        ├── unassigned-list.tsx
        └── card-picker.tsx

Task 1: Card numbers

Files:

  • Modify: scripts/setup-remote.ts

  • Modify: src/lib/pb/types.ts

  • Create: src/lib/cards/number.ts

  • Test: src/lib/cards/number.test.ts

  • Modify: src/app/actions/cards.ts

  • Modify: src/app/api/client/cards/route.ts

  • Step 1: Write the failing tests for number assignment

src/lib/cards/number.test.ts:

import { describe, it, expect } from "vitest";
import { nextCardNumber } from "@/lib/cards/number";

describe("nextCardNumber", () => {
  it("starts at 1 for a project with no cards", () => {
    expect(nextCardNumber([])).toBe(1);
  });

  it("returns one past the highest existing number", () => {
    expect(nextCardNumber([1, 2, 3])).toBe(4);
  });

  it("does not reuse a number freed by a deletion", () => {
    // Reusing 2 would point an old commit's [#2] at a different card.
    expect(nextCardNumber([1, 3])).toBe(4);
  });

  it("ignores zero, negatives and non-integers left by bad data", () => {
    expect(nextCardNumber([0, -5, 2])).toBe(3);
    expect(nextCardNumber([Number.NaN, 4])).toBe(5);
  });

  it("handles an unsorted list", () => {
    expect(nextCardNumber([7, 2, 5])).toBe(8);
  });
});
  • Step 2: Run the tests to verify they fail
npm test

Expected: FAIL — Failed to resolve import "@/lib/cards/number".

  • Step 3: Write the implementation

src/lib/cards/number.ts:

/**
 * Per-project card numbers.
 *
 * Cards need a short identifier a person can type into a commit message —
 * `feat(stock): inline editing [#42]`. PocketBase ids like `2e8izsch98bbc6l`
 * cannot serve that purpose.
 *
 * Numbers are never reused. A freed number would silently repoint an old
 * commit's reference at an unrelated card, and git history cannot be corrected
 * after the fact.
 */
export function nextCardNumber(existing: number[]): number {
  let highest = 0;
  for (const value of existing) {
    if (!Number.isInteger(value)) continue;
    if (value > highest) highest = value;
  }
  return highest + 1;
}
  • Step 4: Run the tests to verify they pass
npm test

Expected: PASS — 5 tests in number.test.ts.

  • Step 5: Add the schema field and backfill

In scripts/setup-remote.ts, add a helper beside ensureProjectIngestFields:

async function ensureCardNumbers(pb: PocketBase): Promise<void> {
  const cards = await pb.collections.getOne("cards");
  const have = new Set(cards.fields.map((f: { name: string }) => f.name));

  if (!have.has("number")) {
    const fields = [...(cards.fields as unknown[])];
    fields.push({
      name: "number",
      type: "number",
      required: false,
      onlyInt: true,
      presentable: false,
      hidden: false,
      system: false,
    });
    await pb.collections.update(cards.id, { fields });
    console.log("  + cards field: number");
  } else {
    console.log("  = cards.number already present");
  }

  // Backfill per project, oldest card first, so numbering matches the order
  // the work was created in rather than the order PocketBase returns.
  const projects = await pb.collection("projects").getFullList({ fields: "id" });
  for (const project of projects) {
    const all = await pb.collection("cards").getFullList({
      filter: `project = "${project.id}"`,
      sort: "created",
      fields: "id,number",
    });

    let next = 1;
    for (const card of all) {
      if (typeof card.number === "number" && card.number > 0) {
        next = Math.max(next, card.number + 1);
        continue;
      }
      await pb.collection("cards").update(card.id, { number: next });
      next += 1;
    }
  }
  console.log("  = card numbers backfilled");
}

Call it after await ensureProjectIngestFields(pb);.

Then add the unique index. PocketBase applies indexes on collection update:

  // A unique index is what makes "never reused" enforceable rather than hoped
  // for. Two concurrent creates that read the same maximum will otherwise both
  // write the same number.
  const cardsCollection = await pb.collections.getOne("cards");
  const indexes = new Set(cardsCollection.indexes as string[]);
  indexes.add(
    "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)"
  );
  await pb.collections.update(cardsCollection.id, { indexes: [...indexes] });
  • Step 6: Add the record type

In src/lib/pb/types.ts, add to CardRecord:

  /** Per-project sequential number, for `[#42]` references in commit messages. */
  number: number;
  • Step 7: Assign a number on both creation paths

In src/app/actions/cards.ts, inside createCard, before the create call:

  const numbered = await pb.collection("cards").getFullList<CardRecord>({
    filter: `project = "${input.projectId}"`,
    fields: "number",
  });

and add number: nextCardNumber(numbered.map((c) => c.number)) to the created record. Import nextCardNumber from @/lib/cards/number.

Do exactly the same in src/app/api/client/cards/route.ts, where the client's request card is created. A client-created card needs a number too — the owner will reference it from a commit once they start the work.

  • Step 8: Apply and verify
set -a && source .env.local && set +a && npm run setup-remote

Then confirm every card has a distinct number within its project:

set -a && source .env.local && set +a && npx tsx -e "
import PocketBase from 'pocketbase';
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!);
pb.autoCancellation(false);
await pb.collection('_superusers').authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!);
const cards = await pb.collection('cards').getFullList({ fields: 'project,number,title', sort: 'created' });
const seen = new Map<string, Set<number>>();
let bad = 0;
for (const c of cards) {
  if (!c.number) { console.log('NO NUMBER:', c.title); bad++; continue; }
  const set = seen.get(c.project) ?? new Set();
  if (set.has(c.number)) { console.log('DUPLICATE:', c.project, c.number, c.title); bad++; }
  set.add(c.number); seen.set(c.project, set);
}
console.log(cards.length, 'cards checked,', bad, 'problems');
"

Expected: N cards checked, 0 problems.

  • Step 9: Commit
git add scripts/setup-remote.ts src/lib/cards src/lib/pb/types.ts src/app/actions/cards.ts src/app/api/client/cards/route.ts
git commit -m "feat(cards): per-project card numbers for commit references"

Task 2: Explicit reference parsing

Pure, test-first.

Files:

  • Create: src/lib/linking/reference.ts

  • Test: src/lib/linking/reference.test.ts

  • Step 1: Write the failing tests

src/lib/linking/reference.test.ts:

import { describe, it, expect } from "vitest";
import { extractCardReference } from "@/lib/linking/reference";

describe("extractCardReference", () => {
  it("finds a bracketed reference at the end of the subject", () => {
    expect(extractCardReference("feat(stock): inline qty editing [#42]")).toBe(42);
  });

  it("finds one anywhere in the message, including the body", () => {
    expect(extractCardReference("[#7] fix: thing")).toBe(7);
    expect(extractCardReference("fix: thing\n\nRefs [#7]\n")).toBe(7);
  });

  it("returns null when there is none", () => {
    expect(extractCardReference("feat(stock): inline qty editing")).toBeNull();
    expect(extractCardReference("")).toBeNull();
  });

  it("ignores a bare #42 without brackets", () => {
    // Bare hashes appear constantly in prose — colours, issue numbers from
    // other systems, ordinals. Requiring brackets makes the reference a
    // deliberate act rather than a guess.
    expect(extractCardReference("fix: matches #42 in the old tracker")).toBeNull();
    expect(extractCardReference("style: use #ff8a5c")).toBeNull();
  });

  it("takes the first reference when several appear", () => {
    // One commit links to at most one card, so a second reference cannot be
    // honoured. First wins, deterministically.
    expect(extractCardReference("fix: thing [#3] and also [#9]")).toBe(3);
  });

  it("rejects zero and negative numbers", () => {
    expect(extractCardReference("fix: thing [#0]")).toBeNull();
    expect(extractCardReference("fix: thing [#-4]")).toBeNull();
  });

  it("rejects a non-numeric reference", () => {
    expect(extractCardReference("fix: thing [#abc]")).toBeNull();
    expect(extractCardReference("fix: thing [#]")).toBeNull();
  });

  it("tolerates whitespace inside the brackets", () => {
    expect(extractCardReference("fix: thing [# 42 ]")).toBe(42);
  });

  it("rejects an absurdly large number rather than overflowing", () => {
    expect(extractCardReference(`fix: thing [#${"9".repeat(20)}]`)).toBeNull();
  });
});
  • Step 2: Run the tests to verify they fail
npm test

Expected: FAIL — Failed to resolve import "@/lib/linking/reference".

  • Step 3: Write the implementation

src/lib/linking/reference.ts:

/**
 * Explicit card references in commit messages: `[#42]`.
 *
 * Brackets are required. A bare `#42` appears far too often in ordinary prose —
 * hex colours, issue numbers from other trackers, ordinals — and a linker that
 * guesses at those attaches work to the wrong card, which is worse than not
 * linking it at all.
 *
 * This is the override, not the daily habit: scope matching carries most
 * commits. It exists for the times auto-linking gets it wrong.
 */

/** Above this, the value is not a card number; it is a typo or an overflow. */
const MAX_CARD_NUMBER = 1_000_000;

const REFERENCE = /\[#\s*(\d+)\s*\]/;

export function extractCardReference(message: string): number | null {
  const match = REFERENCE.exec(message ?? "");
  if (!match) return null;

  const value = Number(match[1]);
  if (!Number.isInteger(value)) return null;
  if (value < 1 || value > MAX_CARD_NUMBER) return null;

  return value;
}
  • Step 4: Run the tests to verify they pass
npm test

Expected: PASS — 9 tests in reference.test.ts.

  • Step 5: Commit
git add src/lib/linking/reference.ts src/lib/linking/reference.test.ts
git commit -m "feat(linking): explicit [#42] card references"

The heart of this plan. Pure, no database, test-first — which is what makes the precedence chain and both sharpenings above verifiable rather than asserted.

Files:

  • Create: src/lib/linking/resolve.ts

  • Test: src/lib/linking/resolve.test.ts

  • Step 1: Write the failing tests

src/lib/linking/resolve.test.ts:

import { describe, it, expect } from "vitest";
import { resolveCommitCard, type LinkCandidate, type LinkableCommit } from "@/lib/linking/resolve";

function card(overrides: Partial<LinkCandidate>): LinkCandidate {
  return {
    id: "card_a",
    number: 1,
    scopeRule: "",
    startDate: "",
    created: "2026-01-01 00:00:00.000Z",
    archived: false,
    ...overrides,
  };
}

function commit(overrides: Partial<LinkableCommit>): LinkableCommit {
  return {
    message: "feat(stock): a thing",
    scope: "stock",
    authoredAt: "2026-06-15 10:00:00.000Z",
    isMerge: false,
    ...overrides,
  };
}

describe("resolveCommitCard — explicit references win", () => {
  it("links by number regardless of scope", () => {
    const cards = [
      card({ id: "by_scope", number: 1, scopeRule: "stock" }),
      card({ id: "by_number", number: 42, scopeRule: "quotes" }),
    ];
    const result = resolveCommitCard(
      commit({ message: "feat(stock): thing [#42]" }),
      cards
    );
    expect(result).toEqual({ cardId: "by_number", source: "explicit" });
  });

  it("links by number even before the card's bound", () => {
    // An explicit reference is a human statement of intent. Date bounding
    // exists to stop SCOPE rules over-reaching, not to overrule a person.
    const cards = [card({ id: "late", number: 42, created: "2026-12-01 00:00:00.000Z" })];
    const result = resolveCommitCard(commit({ message: "fix: thing [#42]" }), cards);
    expect(result).toEqual({ cardId: "late", source: "explicit" });
  });

  it("falls through to scope when the number matches no card", () => {
    const cards = [card({ id: "scoped", number: 1, scopeRule: "stock" })];
    const result = resolveCommitCard(
      commit({ message: "feat(stock): thing [#999]" }),
      cards
    );
    expect(result).toEqual({ cardId: "scoped", source: "scope" });
  });
});

describe("resolveCommitCard — scope matching", () => {
  it("links a commit to the card owning its scope", () => {
    const cards = [card({ id: "stock", scopeRule: "stock" })];
    expect(resolveCommitCard(commit({}), cards)).toEqual({
      cardId: "stock",
      source: "scope",
    });
  });

  it("does not link when the commit has no scope", () => {
    const cards = [card({ id: "stock", scopeRule: "stock" })];
    expect(resolveCommitCard(commit({ scope: "" }), cards)).toEqual({
      cardId: null,
      source: "none",
    });
  });

  it("matches case-insensitively", () => {
    const cards = [card({ id: "stock", scopeRule: "Stock" })];
    expect(resolveCommitCard(commit({ scope: "stock" }), cards).cardId).toBe("stock");
  });

  it("ignores cards with no scope rule", () => {
    const cards = [card({ id: "plain", scopeRule: "" })];
    expect(resolveCommitCard(commit({}), cards).cardId).toBeNull();
  });

  it("ignores archived cards", () => {
    const cards = [card({ id: "old", scopeRule: "stock", archived: true })];
    expect(resolveCommitCard(commit({}), cards).cardId).toBeNull();
  });
});

describe("resolveCommitCard — date bounding", () => {
  it("does not let a card claim commits from before it existed", () => {
    // The rot this prevents: a new "Stock pricing v2" card swallowing a year
    // of old feat(stock) commits and claiming work it did not do.
    const cards = [
      card({ id: "new", scopeRule: "stock", created: "2026-08-01 00:00:00.000Z" }),
    ];
    expect(
      resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards)
        .cardId
    ).toBeNull();
  });

  it("uses start_date when set", () => {
    const cards = [
      card({
        id: "started",
        scopeRule: "stock",
        created: "2026-01-01 00:00:00.000Z",
        startDate: "2026-08-01 00:00:00.000Z",
      }),
    ];
    expect(
      resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards)
        .cardId
    ).toBeNull();
  });

  it("falls back to created when start_date is empty", () => {
    // Owner-created cards never get a start_date — only the accept flow sets
    // one — so a literal start_date-only rule would make them match nothing.
    const cards = [
      card({ id: "owner", scopeRule: "stock", startDate: "", created: "2026-01-01 00:00:00.000Z" }),
    ];
    expect(resolveCommitCard(commit({}), cards).cardId).toBe("owner");
  });

  it("includes a commit exactly on the bound", () => {
    const cards = [
      card({ id: "exact", scopeRule: "stock", created: "2026-06-15 10:00:00.000Z" }),
    ];
    expect(resolveCommitCard(commit({}), cards).cardId).toBe("exact");
  });
});

describe("resolveCommitCard — one commit, one card", () => {
  it("gives the commit to the eligible card with the latest bound", () => {
    const cards = [
      card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
      card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }),
    ];
    expect(resolveCommitCard(commit({}), cards).cardId).toBe("v2");
  });

  it("leaves an earlier commit with the earlier card", () => {
    const cards = [
      card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
      card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }),
    ];
    expect(
      resolveCommitCard(commit({ authoredAt: "2026-03-01 10:00:00.000Z" }), cards)
        .cardId
    ).toBe("v1");
  });

  it("breaks an exact tie by the higher card number, deterministically", () => {
    const cards = [
      card({ id: "a", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
      card({ id: "b", number: 2, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
    ];
    expect(resolveCommitCard(commit({}), cards).cardId).toBe("b");
    // Order of the input must not change the answer.
    expect(resolveCommitCard(commit({}), [...cards].reverse()).cardId).toBe("b");
  });
});

describe("resolveCommitCard — merges", () => {
  it("never links a merge commit", () => {
    // A merge is not a unit of work; attaching it to a card would inflate that
    // card's commit count and drag its session boundaries around.
    const cards = [card({ id: "stock", scopeRule: "stock" })];
    expect(resolveCommitCard(commit({ isMerge: true }), cards)).toEqual({
      cardId: null,
      source: "none",
    });
  });

  it("does not link a merge even with an explicit reference", () => {
    const cards = [card({ id: "stock", number: 42 })];
    expect(
      resolveCommitCard(
        commit({ isMerge: true, message: "Merge branch 'x' [#42]" }),
        cards
      ).cardId
    ).toBeNull();
  });
});
  • Step 2: Run the tests to verify they fail
npm test

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

  • Step 3: Write the implementation

src/lib/linking/resolve.ts:

/**
 * Decide which card a commit belongs to.
 *
 * Pure and database-free, so the precedence chain is verifiable rather than
 * asserted. Every rule here exists to stop a specific kind of quiet wrongness —
 * see the tests, which name each one.
 */

import { extractCardReference } from "@/lib/linking/reference";

export interface LinkCandidate {
  id: string;
  number: number;
  /** The conventional-commit scope this card claims, or "". */
  scopeRule: string;
  /** PocketBase date string, or "" when never started. */
  startDate: string;
  created: string;
  archived: boolean;
}

export interface LinkableCommit {
  message: string;
  /** Parsed conventional-commit scope, or "". */
  scope: string;
  authoredAt: string;
  isMerge: boolean;
}

export type LinkSource = "explicit" | "scope" | "none";

export interface LinkResolution {
  cardId: string | null;
  source: LinkSource;
}

const UNLINKED: LinkResolution = { cardId: null, source: "none" };

/**
 * The earliest commit a card may claim by scope.
 *
 * `start_date` when the card was accepted through the Inbox flow, otherwise
 * `created`. A literal start_date-only rule would exclude every card the owner
 * created directly, which is most of them.
 */
function bound(card: LinkCandidate): string {
  return card.startDate || card.created;
}

/** PocketBase dates compare correctly as strings once normalised to ISO. */
function comparable(value: string): string {
  return (value ?? "").replace(" ", "T");
}

export function resolveCommitCard(
  commit: LinkableCommit,
  candidates: LinkCandidate[]
): LinkResolution {
  // A merge is real history but not a unit of work. Linking one would inflate
  // a card's commit count and distort its effort estimate.
  if (commit.isMerge) return UNLINKED;

  const live = candidates.filter((c) => !c.archived);

  // 1. Explicit reference. Deliberately not date-bounded: bounding exists to
  //    stop scope rules over-reaching, not to overrule a person who typed a
  //    card number on purpose.
  const referenced = extractCardReference(commit.message);
  if (referenced !== null) {
    const match = live.find((c) => c.number === referenced);
    if (match) return { cardId: match.id, source: "explicit" };
    // Fall through: a stale or mistyped number should still let scope work.
  }

  // 2. Scope rule, bounded by the card's start.
  if (!commit.scope) return UNLINKED;

  const authored = comparable(commit.authoredAt);

  const eligible = live.filter(
    (c) =>
      c.scopeRule &&
      c.scopeRule.toLowerCase() === commit.scope.toLowerCase() &&
      comparable(bound(c)) <= authored
  );

  if (eligible.length === 0) return UNLINKED;

  // One commit, one card. The winner is the eligible card that started most
  // recently — so "Stock pricing v2" takes July's commits while the January
  // card keeps January's. Ties break on the higher number so the answer never
  // depends on the order the candidates arrived in.
  const winner = eligible.reduce((best, current) => {
    const a = comparable(bound(best));
    const b = comparable(bound(current));
    if (b > a) return current;
    if (b < a) return best;
    return current.number > best.number ? current : best;
  });

  return { cardId: winner.id, source: "scope" };
}
  • Step 4: Run the tests to verify they pass
npm test

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

  • Step 5: Commit
git add src/lib/linking/resolve.ts src/lib/linking/resolve.test.ts
git commit -m "feat(linking): commit-to-card resolution with date bounding"

Task 4: The linking engine

Files:

  • Create: src/lib/linking/engine.ts

  • Step 1: Write the engine

src/lib/linking/engine.ts:

import "server-only";

import { getAdminPb } from "@/lib/pb/admin";
import { resolveCommitCard, type LinkCandidate } from "@/lib/linking/resolve";
import type { CardRecord, CommitRecord } from "@/lib/pb/types";

export interface RelinkResult {
  examined: number;
  linked: number;
  unlinked: number;
  preserved: number;
}

/**
 * Recompute every derived link on a project.
 *
 * Idempotent and safe to run repeatedly: it reads the current state, decides
 * again from the rules, and writes only where the answer changed.
 *
 * `manual` links are PRESERVED, never recomputed. A link the owner made by hand
 * from the Unassigned tray is a human decision that outranks every rule in
 * resolve.ts — overwriting it on the next push would make the tray pointless
 * and would look, from the owner's side, like the app undoing their work.
 */
export async function relinkProject(projectId: string): Promise<RelinkResult> {
  const pb = await getAdminPb();

  const cards = await pb.collection("cards").getFullList<CardRecord>({
    filter: `project = "${projectId}"`,
    fields: "id,number,scope_rule,start_date,created,archived",
  });

  const candidates: LinkCandidate[] = cards.map((card) => ({
    id: card.id,
    number: card.number,
    scopeRule: card.scope_rule ?? "",
    startDate: card.start_date ?? "",
    created: card.created,
    archived: Boolean(card.archived),
  }));

  const commits = await pb.collection("commits").getFullList<CommitRecord>({
    filter: `project = "${projectId}"`,
    fields: "id,message,scope,authored_at,is_merge,card,link_source",
  });

  const result: RelinkResult = {
    examined: 0,
    linked: 0,
    unlinked: 0,
    preserved: 0,
  };

  for (const commit of commits) {
    if (commit.link_source === "manual") {
      result.preserved += 1;
      continue;
    }

    result.examined += 1;

    const resolution = resolveCommitCard(
      {
        message: commit.message ?? "",
        scope: commit.scope ?? "",
        authoredAt: commit.authored_at,
        isMerge: Boolean(commit.is_merge),
      },
      candidates
    );

    const nextCard = resolution.cardId ?? "";
    const nextSource = resolution.source;

    // Write only on change. A no-op update would bump `updated` on every
    // commit row on every push, which makes the collection useless for
    // spotting what actually moved.
    if (commit.card === nextCard && commit.link_source === nextSource) continue;

    await pb.collection("commits").update(commit.id, {
      card: nextCard,
      link_source: nextSource,
    });

    if (nextCard) result.linked += 1;
    else result.unlinked += 1;
  }

  return result;
}

/** Attach a commit to a card by hand. Outranks and survives re-linking. */
export async function linkCommitManually(
  commitId: string,
  cardId: string
): Promise<void> {
  const pb = await getAdminPb();
  await pb
    .collection("commits")
    .update(commitId, { card: cardId, link_source: "manual" });
}

/**
 * Detach a commit.
 *
 * Reset to `none` rather than to `manual`, so the next re-link is free to
 * reconsider it — "this is not right" should not also mean "never look at this
 * again".
 */
export async function unlinkCommit(commitId: string): Promise<void> {
  const pb = await getAdminPb();
  await pb.collection("commits").update(commitId, { card: "", link_source: "none" });
}
  • Step 2: Verify the build
npm run build

Expected: build succeeds.

  • Step 3: Commit
git add src/lib/linking/engine.ts
git commit -m "feat(linking): idempotent relink engine preserving manual links"

Task 5: Triggers

Linking must re-run whenever either side of the rules changes.

Files:

  • Modify: src/lib/git/ingest.ts

  • Modify: src/app/actions/cards.ts

  • Create: src/app/actions/linking.ts

  • Step 1: Re-link after every ingest

In src/lib/git/ingest.ts, at the end of ingestProject, after the last_ingest_at update and before the return:

  // New commits mean new links. Doing this here rather than in each caller
  // means the webhook, the poll and the backfill all get it, and none of them
  // has to remember.
  const { relinkProject } = await import("@/lib/linking/engine");
  await relinkProject(project.id);

The dynamic import avoids a cycle: the engine imports from @/lib/pb/admin, which this module also uses, and a static import here creates a loop through the barrel.

  • Step 2: Re-link when a card's rules change

In src/app/actions/cards.ts:

  • updateCard already accepts scopeRule. After a successful update where input.scopeRule !== undefined, call relinkProject(card.project).
  • acceptCard sets start_date, which moves a card's bound. Call relinkProject(card.project) after the update.
  • archiveCard and unarchiveCard change eligibility. Call relinkProject(card.project) after each.

Import at the top: import { relinkProject } from "@/lib/linking/engine";

  • Step 3: Write the linking actions

src/app/actions/linking.ts:

"use server";

import { revalidatePath } from "next/cache";

import { requireSession } from "@/lib/auth/session";
import {
  linkCommitManually,
  relinkProject,
  unlinkCommit,
} from "@/lib/linking/engine";

/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
  const session = await requireSession();
  if (session.role !== "owner") throw new Error("Not found");
  return session;
}

export async function relinkProjectAction(input: {
  projectId: string;
  clientSlug: string;
  projectSlug: string;
}): Promise<{ linked: number; unlinked: number; preserved: number }> {
  await requireOwner();
  const result = await relinkProject(input.projectId);

  revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
  return {
    linked: result.linked,
    unlinked: result.unlinked,
    preserved: result.preserved,
  };
}

export async function linkCommitAction(input: {
  commitId: string;
  cardId: string;
  clientSlug: string;
  projectSlug: string;
}): Promise<void> {
  await requireOwner();
  await linkCommitManually(input.commitId, input.cardId);
  revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
}

export async function unlinkCommitAction(input: {
  commitId: string;
  clientSlug: string;
  projectSlug: string;
}): Promise<void> {
  await requireOwner();
  await unlinkCommit(input.commitId);
  revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
}
  • Step 4: Verify the build
npm run build

Expected: build succeeds.

  • Step 5: Commit
git add src/lib/git/ingest.ts src/app/actions
git commit -m "feat(linking): re-link on ingest and on card rule changes"

Task 6: The Unassigned tray

Files:

  • Create: src/components/linking/unassigned-list.tsx

  • Create: src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx

  • Step 1: Write the list

src/components/linking/unassigned-list.tsx:

"use client";

import { useState, useTransition } from "react";
import { RefreshCw } from "lucide-react";

import { linkCommitAction, relinkProjectAction } from "@/app/actions/linking";
import { relativeTime } from "@/lib/time/relative";
import { Pill } from "@/components/ui/pill";
import type { CommitRecord } from "@/lib/pb/types";

export interface CardOption {
  id: string;
  number: number;
  title: string;
}

export function UnassignedList({
  projectId,
  commits,
  cards,
  clientSlug,
  projectSlug,
}: {
  projectId: string;
  commits: CommitRecord[];
  cards: CardOption[];
  clientSlug: string;
  projectSlug: string;
}) {
  const [pending, startTransition] = useTransition();
  const [status, setStatus] = useState<string | null>(null);

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-3">
        <button
          type="button"
          disabled={pending}
          onClick={() =>
            startTransition(async () => {
              const result = await relinkProjectAction({
                projectId,
                clientSlug,
                projectSlug,
              });
              setStatus(
                `${result.linked} linked, ${result.unlinked} left unassigned, ${result.preserved} manual links kept.`
              );
            })
          }
          className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-raised disabled:opacity-50"
        >
          <RefreshCw size={14} strokeWidth={2} />
          Re-run linking
        </button>
        {status && <span className="text-xs text-text-muted">{status}</span>}
      </div>

      {commits.length === 0 ? (
        <p className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-8 text-center text-sm text-text-muted">
          Every commit is attached to a card.
        </p>
      ) : (
        <ul className="space-y-2">
          {commits.map((commit) => (
            <li
              key={commit.id}
              className="flex items-center gap-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3"
            >
              <div className="min-w-0 flex-1">
                <p className="truncate text-sm text-text-primary">
                  {commit.subject || commit.short_sha}
                </p>
                <p className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
                  <code>{commit.short_sha}</code>
                  <span>{relativeTime(commit.authored_at)}</span>
                  {commit.type && commit.type !== "other" && (
                    <Pill tone="neutral">{commit.type}</Pill>
                  )}
                  {commit.scope && <Pill tone="brand">{commit.scope}</Pill>}
                </p>
              </div>

              <label className="shrink-0">
                <span className="sr-only">Attach {commit.short_sha} to a card</span>
                <select
                  defaultValue=""
                  disabled={pending}
                  onChange={(event) => {
                    const cardId = event.target.value;
                    if (!cardId) return;
                    startTransition(async () => {
                      await linkCommitAction({
                        commitId: commit.id,
                        cardId,
                        clientSlug,
                        projectSlug,
                      });
                    });
                  }}
                  className="rounded-lg border border-border-subtle bg-panel px-2 py-1.5 text-xs text-text-secondary outline-none focus:border-brand disabled:opacity-50"
                >
                  <option value="">Attach to…</option>
                  {cards.map((card) => (
                    <option key={card.id} value={card.id}>
                      #{card.number} · {card.title.slice(0, 50)}
                    </option>
                  ))}
                </select>
              </label>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • Step 2: Write the page

src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx:

import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";

import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { UnassignedList, type CardOption } from "@/components/linking/unassigned-list";
import { getSession } from "@/lib/auth/session";
import { getServerPb } from "@/lib/pb/server";
import { loadBoard } from "@/lib/data/board";
import type { CommitRecord } from "@/lib/pb/types";

/**
 * Owner-only. A client manager has no business reassigning commits, and the
 * tray is a working surface rather than a progress report.
 */
export default async function UnassignedPage({
  params,
}: {
  params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
  const session = await getSession();
  if (!session) redirect("/login");
  if (session.role !== "owner") notFound();

  const { clientSlug, projectSlug } = await params;
  const board = await loadBoard(clientSlug, projectSlug);
  if (!board) notFound();

  const pb = await getServerPb();

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

  const cards: CardOption[] = board.cards
    .filter((card) => !card.archived)
    .sort((a, b) => a.number - b.number)
    .map((card) => ({ id: card.id, number: card.number, title: card.title }));

  const boardHref = `/p/${clientSlug}/${projectSlug}`;

  return (
    <AppShell role={session.role} currentPath={boardHref}>
      <TopBar
        title="Unassigned commits"
        subtitle={`${board.project.name} · ${commits.length} not attached to a card`}
      />

      <div className="flex-1 space-y-4 overflow-y-auto p-6">
        <Link
          href={boardHref}
          className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
        >
          <ArrowLeft size={13} strokeWidth={2} />
          Back to board
        </Link>

        <UnassignedList
          projectId={board.project.id}
          commits={commits}
          cards={cards}
          clientSlug={clientSlug}
          projectSlug={projectSlug}
        />
      </div>
    </AppShell>
  );
}

Merge commits are filtered out of the tray. They are never linkable, so listing them would present the owner with hundreds of rows they can do nothing about.

  • Step 3: Link it from the board

In src/app/p/[clientSlug]/[projectSlug]/page.tsx, inside <TopBar>, add alongside the existing Archive link, rendered only for the owner:

        {session.role === "owner" && (
          <Link
            href={`/p/${clientSlug}/${projectSlug}/unassigned`}
            className="text-xs text-text-muted hover:text-text-secondary"
          >
            Unassigned
          </Link>
        )}
  • Step 4: Verify
npm run build

Expected: build succeeds; the route list includes /p/[clientSlug]/[projectSlug]/unassigned.

  • Step 5: Commit
git add src/components/linking src/app/p
git commit -m "feat(linking): unassigned commit tray with manual attachment"

Task 7: Integration tests

Files:

  • Modify: vitest.config.ts

  • Create: tests/integration/linking.test.ts

  • Step 1: Let integration tests import server modules

These are the first integration tests that import application code rather than only calling HTTP endpoints, and that breaks on server-only.

That package resolves to an empty module under the react-server export condition and to a module that throws on import under any other. Vitest does not set the condition, so import("@/lib/linking/engine") fails immediately with "This module cannot be imported from a Client Component module." — confusing, because nothing here is a client component.

In vitest.config.ts, add the condition to the integration project only:

      {
        resolve: {
          alias: { "@": path.resolve(__dirname, "./src") },
          // server-only throws on import unless this condition is set. The unit
          // project deliberately does NOT set it: pure modules must stay free
          // of server imports, and a failure there is the signal that one crept
          // in.
          conditions: ["react-server"],
        },
        test: {
          name: "integration",
          environment: "node",
          include: ["tests/integration/**/*.test.ts"],
          hookTimeout: 30_000,
          testTimeout: 30_000,
          fileParallelism: false,
        },
      },
  • Step 2: Write the tests

tests/integration/linking.test.ts:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";

import { DEMO, adminPb, requireServices } from "./helpers";

let admin: PocketBase;
let projectId: string;
let columnId: string;
const created: Array<{ collection: string; id: string }> = [];

async function makeCard(fields: Record<string, unknown>) {
  const numbered = await admin
    .collection("cards")
    .getFullList({ filter: `project = "${projectId}"`, fields: "number" });
  const next =
    Math.max(0, ...numbered.map((c) => Number(c.number) || 0)) + 1;

  const card = await admin.collection("cards").create({
    project: projectId,
    column: columnId,
    title: "Linking probe",
    position: 1_000_000 + next,
    priority: "medium",
    created_by_role: "owner",
    accepted: true,
    archived: false,
    number: next,
    ...fields,
  });
  created.push({ collection: "cards", id: card.id });
  return card;
}

async function makeCommit(fields: Record<string, unknown>) {
  const commit = await admin.collection("commits").create({
    project: projectId,
    sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`,
    short_sha: "probe00",
    message: "feat(probe): a thing",
    subject: "a thing",
    type: "feat",
    scope: "probe",
    author_name: "Dave",
    author_email: "dave@example.com",
    authored_at: new Date().toISOString(),
    is_merge: false,
    link_source: "none",
    ...fields,
  });
  created.push({ collection: "commits", id: commit.id });
  return commit;
}

beforeAll(async () => {
  await requireServices();
  admin = await adminPb();

  const project = await admin
    .collection("projects")
    .getFirstListItem(`slug = "${DEMO.projectSlug}"`);
  projectId = project.id;

  columnId = (
    await admin
      .collection("columns")
      .getFirstListItem(`project = "${projectId}" && is_inbox = true`)
  ).id;
});

afterAll(async () => {
  for (const item of created.reverse()) {
    await admin.collection(item.collection).delete(item.id).catch(() => {});
  }
});

describe("relinking is idempotent and preserves manual links", () => {
  it("links by scope, and running twice changes nothing", async () => {
    const { relinkProject } = await import("@/lib/linking/engine");

    const card = await makeCard({ scope_rule: "probe" });
    const commit = await makeCommit({});

    await relinkProject(projectId);
    const first = await admin.collection("commits").getOne(commit.id);
    expect(first.card).toBe(card.id);
    expect(first.link_source).toBe("scope");

    await relinkProject(projectId);
    const second = await admin.collection("commits").getOne(commit.id);
    expect(second.card).toBe(card.id);
    // `updated` must not move: re-linking writes only where the answer changed.
    expect(second.updated).toBe(first.updated);
  });

  it("never overwrites a manual link", async () => {
    const { relinkProject, linkCommitManually } = await import(
      "@/lib/linking/engine"
    );

    const scoped = await makeCard({ scope_rule: "probe" });
    const other = await makeCard({ scope_rule: "" });
    const commit = await makeCommit({});

    await linkCommitManually(commit.id, other.id);
    await relinkProject(projectId);

    const after = await admin.collection("commits").getOne(commit.id);
    expect(after.card).toBe(other.id);
    expect(after.card).not.toBe(scoped.id);
    expect(after.link_source).toBe("manual");
  });

  it("does not let a card claim commits from before it existed", async () => {
    const { relinkProject } = await import("@/lib/linking/engine");

    const old = await makeCommit({
      scope: "backdated",
      authored_at: "2020-01-01T00:00:00.000Z",
    });
    await makeCard({ scope_rule: "backdated" });

    await relinkProject(projectId);

    const after = await admin.collection("commits").getOne(old.id);
    expect(after.card).toBe("");
    expect(after.link_source).toBe("none");
  });

  it("never links a merge commit", async () => {
    const { relinkProject } = await import("@/lib/linking/engine");

    await makeCard({ scope_rule: "mergeprobe" });
    const merge = await makeCommit({ scope: "mergeprobe", is_merge: true });

    await relinkProject(projectId);

    const after = await admin.collection("commits").getOne(merge.id);
    expect(after.card).toBe("");
  });
});
  • Step 3: Run the integration suite

With npm run dev running:

set -a && source .env.local && set +a && npm run test:integration

Expected: PASS — 34 tests (30 after Plan 2a, plus 4 here).

  • Step 4: Commit
git add tests/integration/linking.test.ts vitest.config.ts
git commit -m "test(linking): idempotency, manual precedence and date bounding"

Task 8: Full verification

This task is work, not a summary. Run every command and act on what it prints.

  • Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all

Expected: PASS. Unit tests total 159 — the 128 after Plan 2a, plus 5 in number.test.ts, 9 in reference.test.ts and 17 in resolve.test.ts. Integration tests total 34.

  • Step 2: Verify the production build
npm run build

Expected: no type errors. The route list includes /p/[clientSlug]/[projectSlug]/unassigned.

  • Step 3: Link the real history

On the demo project, which now holds WFDASH-WEBAPP's 787 commits, set a card's scope_rule to a scope that genuinely appears — quotes or stock — and set that card's start_date early enough to cover the history. Re-run linking from the tray.

set -a && source .env.local && set +a && npx tsx -e "
import PocketBase from 'pocketbase';
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!);
pb.autoCancellation(false);
await pb.collection('_superusers').authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!);
const all = await pb.collection('commits').getFullList({ fields: 'scope,card,link_source,is_merge' });
const bySource: Record<string, number> = {};
for (const c of all) bySource[c.link_source || 'none'] = (bySource[c.link_source || 'none'] ?? 0) + 1;
const scopes: Record<string, number> = {};
for (const c of all) if (c.scope) scopes[c.scope] = (scopes[c.scope] ?? 0) + 1;
console.log('by link_source:', bySource);
console.log('merges:', all.filter(c => c.is_merge).length);
console.log('top scopes:', Object.entries(scopes).sort((a,b)=>b[1]-a[1]).slice(0,8));
console.log('linked merges (must be 0):', all.filter(c => c.is_merge && c.card).length);
"

Expected: commits with the chosen scope are linked, linked merges is 0, and the top scopes match the "Feature areas" breakdown in the Hermes build log.

  • Step 4: Check the tray

Open /p/<client>/<project>/unassigned.

Expected: the unlinked, non-merge commits are listed newest first. Attach one to a card by hand, then press Re-run linking and confirm it stays attached and the status line reports it among the preserved manual links.

  • Step 5: Check the explicit reference end to end

Make a commit in the linked repository whose message ends with [#N], where N is a real card number on that project, and push it.

Expected: within seconds it is linked to card N with link_source: "explicit", even if its scope would have sent it elsewhere.

  • Step 6: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 2b verification"

Definition of done

  1. npm run test:all passes: 159 unit, 34 integration.
  2. npm run build succeeds with no type errors.
  3. Every card has a unique number within its project, and both creation paths assign one.
  4. [#42] links a commit to card 42 regardless of its scope.
  5. Scope linking respects start_date || created and never back-claims.
  6. No merge commit is ever linked.
  7. Re-running linking changes nothing that has not genuinely changed, and never disturbs a manual link.
  8. The Unassigned tray lists unlinked non-merge commits and can attach them.

Deliberately not in this plan

  • Rendering a card's commits on the card detail page. The links exist; the display lands in Phase 3, when those commits finally have readable summaries to show instead of raw conventional-commit subjects.
  • Session clustering, workdays, metrics and the dashboard — Plan 2c.
  • Bulk operations in the tray. Attaching commits one at a time is tedious across hundreds of rows; add multi-select once it actually hurts, since the scope rules should keep the tray small.
  • A scope-rule picker on the card. scope_rule is currently set through the admin or the database. A suggestions UI — showing which scopes exist and how many commits each holds — would be a good Phase 3 addition once there is data to populate it.