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>
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
[#42]in the message → card number 42 in this project.- The card's
scope_rulematching the commit's parsed scope. - 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"
Task 3: Link resolution
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:
updateCardalready acceptsscopeRule. After a successful update whereinput.scopeRule !== undefined, callrelinkProject(card.project).acceptCardsetsstart_date, which moves a card's bound. CallrelinkProject(card.project)after the update.archiveCardandunarchiveCardchange eligibility. CallrelinkProject(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
npm run test:allpasses: 159 unit, 34 integration.npm run buildsucceeds with no type errors.- Every card has a unique number within its project, and both creation paths assign one.
[#42]links a commit to card 42 regardless of its scope.- Scope linking respects
start_date || createdand never back-claims. - No merge commit is ever linked.
- Re-running linking changes nothing that has not genuinely changed, and never disturbs a manual link.
- 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_ruleis 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.