# 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`: ```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** ```bash npm test ``` Expected: FAIL — `Failed to resolve import "@/lib/cards/number"`. - [ ] **Step 3: Write the implementation** `src/lib/cards/number.ts`: ```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** ```bash 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`: ```ts async function ensureCardNumbers(pb: PocketBase): Promise { 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: ```ts // 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`: ```ts /** 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: ```ts const numbered = await pb.collection("cards").getFullList({ 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** ```bash set -a && source .env.local && set +a && npm run setup-remote ``` Then confirm every card has a distinct number within its project: ```bash 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>(); 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** ```bash 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`: ```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** ```bash npm test ``` Expected: FAIL — `Failed to resolve import "@/lib/linking/reference"`. - [ ] **Step 3: Write the implementation** `src/lib/linking/reference.ts`: ```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** ```bash npm test ``` Expected: PASS — 9 tests in `reference.test.ts`. - [ ] **Step 5: Commit** ```bash 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`: ```ts import { describe, it, expect } from "vitest"; import { resolveCommitCard, type LinkCandidate, type LinkableCommit } from "@/lib/linking/resolve"; function card(overrides: Partial): 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 { 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** ```bash npm test ``` Expected: FAIL — `Failed to resolve import "@/lib/linking/resolve"`. - [ ] **Step 3: Write the implementation** `src/lib/linking/resolve.ts`: ```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** ```bash npm test ``` Expected: PASS — 17 tests in `resolve.test.ts`. - [ ] **Step 5: Commit** ```bash 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`: ```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 { const pb = await getAdminPb(); const cards = await pb.collection("cards").getFullList({ 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({ 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 { 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 { const pb = await getAdminPb(); await pb.collection("commits").update(commitId, { card: "", link_source: "none" }); } ``` - [ ] **Step 2: Verify the build** ```bash npm run build ``` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash 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: ```ts // 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`: ```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 { 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 { await requireOwner(); await unlinkCommit(input.commitId); revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`); } ``` - [ ] **Step 4: Verify the build** ```bash npm run build ``` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash 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`: ```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(null); return (
{status && {status}}
{commits.length === 0 ? (

Every commit is attached to a card.

) : (
    {commits.map((commit) => (
  • {commit.subject || commit.short_sha}

    {commit.short_sha} {relativeTime(commit.authored_at)} {commit.type && commit.type !== "other" && ( {commit.type} )} {commit.scope && {commit.scope}}

  • ))}
)}
); } ``` - [ ] **Step 2: Write the page** `src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx`: ```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({ 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 (
Back to board
); } ``` 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 ``, add alongside the existing Archive link, rendered only for the owner: ```tsx {session.role === "owner" && ( Unassigned )} ``` - [ ] **Step 4: Verify** ```bash npm run build ``` Expected: build succeeds; the route list includes `/p/[clientSlug]/[projectSlug]/unassigned`. - [ ] **Step 5: Commit** ```bash 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: ```ts { 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`: ```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) { 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) { 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: ```bash 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** ```bash 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** ```bash 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** ```bash 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. ```bash 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 = {}; for (const c of all) bySource[c.link_source || 'none'] = (bySource[c.link_source || 'none'] ?? 0) + 1; const scopes: Record = {}; 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///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** ```bash 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.