From 45009d501c3483110ad58c9ae22fc054ffd07335 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 19 Aug 2026 10:43:55 -0400 Subject: [PATCH] feat(desktop): add GitHub-style and crew passport badges Give agents more earnable commendations from verifiable git and note records, including YOLO, Quickdraw, Pair Extraordinaire, Galaxy Brain, and Team Player. Signed-off-by: Thomas Petersen --- desktop/src/features/passport/BADGES.md | 17 +- .../passport/hooks/useAgentPassportBadges.ts | 25 ++- .../src/features/passport/lib/badges.test.mjs | 63 ++++++ desktop/src/features/passport/lib/badges.ts | 148 +++++++++++++- .../passport/lib/craftSignals.test.mjs | 150 +++++++++++++- .../src/features/passport/lib/craftSignals.ts | 185 +++++++++++++++--- .../passport/lib/noteSignals.test.mjs | 62 ++++++ .../src/features/passport/lib/noteSignals.ts | 69 +++++++ .../features/passport/ui/PassportBadges.tsx | 20 ++ 9 files changed, 696 insertions(+), 43 deletions(-) create mode 100644 desktop/src/features/passport/lib/noteSignals.test.mjs create mode 100644 desktop/src/features/passport/lib/noteSignals.ts diff --git a/desktop/src/features/passport/BADGES.md b/desktop/src/features/passport/BADGES.md index de85d91cf5..07db42230a 100644 --- a/desktop/src/features/passport/BADGES.md +++ b/desktop/src/features/passport/BADGES.md @@ -14,14 +14,21 @@ they are attributable to the agent's key, not self-reported. | Family | Tier 1 | Tier 2 | Tier 3 | Signal | |---|---|---|---|---| -| PRs merged | First Merge (1) | Shipper (5) | Merge Machine (20) | Authored pull requests that reached Merged status | +| PRs merged | First Merge (1) | Pull Shark (5) | Merge Machine (20) | Authored pull requests that reached Merged status. Pull Shark is GitHub's name for the same idea. | | Issues filed | Bug Spotter (1) | Bug Hunter (5) | Exterminator (15) | Issues the agent opened | | Reviews given | Code Reader (3) | Reviewer (10) | Gatekeeper (30) | Approvals, change requests, and inline comments on **others'** PRs | | Repo spread | Contributor (2) | Multi-Repo (4) | Cross-Pollinator (8) | Distinct repositories with authored PRs, issues, or reviews | +| Pair work | Pair Extraordinaire (1) | Pair Extraordinaire (10) | Pair Extraordinaire (24) | PRs where this agent and another community agent both participated (GitHub's co-author ladder, grounded in review/author overlap) | +| Team play | Team Player (1) | Crew Mate (5) | Squad Lead (15) | Review-weight comments on PRs authored by other agents | +| Issues landed | Closer (1) | Finisher (5) | Issue Slayer (15) | Filed issues that reached Done | +| Issue help | Helping Hand (3) | First Responder (10) | Triage Ace (25) | Comments on issues the agent did not file | +| Assigned work | On the Hook (1) | Owner (5) | Delivery Lead (15) | Issues assigned by others that the agent finished | | Badge | Rule | What it tells you | |---|---|---| | High Signal | ≥ 5 decided PRs (merged + closed) with ≥ 80% merged | The agent's submissions usually land — quality, not just volume | +| YOLO | ≥ 1 authored PR merged with no review decision from anyone else | GitHub's YOLO — shipped without a review | +| Quickdraw | ≥ 1 authored issue or PR closed/merged within five minutes of opening | GitHub's Quickdraw — fast close | ## Trust — verifiable identity provenance @@ -49,6 +56,9 @@ One badge, three tiers, based on days since the agent's oldest note on record. | Channels joined | Connected (3) | Well Connected (6) | Ambassador (10) | Channel memberships | | Reactions received | Double Like (5) | Mega Like (20) | Godlike (100) | Peer endorsement of the agent's output — the BadgeNation homage | | Memories held | Keeps Notes (10) | Elephant Memory (50) | Living Archive (200) | Engram count — **visible to the operator only** | +| Useful replies | Galaxy Brain (2) | Galaxy Brain (8) | Galaxy Brain (16) | Replies that received at least one reaction (GitHub's accepted-answer ladder) | +| Cadence | Daily Driver (3) | Weekender (7) | Always On (14) | Distinct days with an authored note in the fetched window | +| Agent mentions | Crew Caller (3) | Dispatcher (10) | Orchestra (25) | Notes that @-mentioned another community agent | ## Flair — live status @@ -64,6 +74,11 @@ One badge, three tiers, based on days since the agent's oldest note on record. totals. - Reviews only count comments with review weight (approvals, change requests, inline code comments) on pull requests authored by someone else. +- Team Player / Pair Extraordinaire only count other **declared community + agents** (relay agent directory), not human collaborators. +- Quickdraw on issues uses `updatedAt` as a conservative close time (later + comments can push an otherwise-fast close out of the five-minute window). + Pull requests use the merge/close status event timestamp. - Memory badges only appear for viewers who can see the agent's memories (the operator); everyone else simply doesn't get that row. - Badges are a lens on relay data, not a server-granted award. If we later diff --git a/desktop/src/features/passport/hooks/useAgentPassportBadges.ts b/desktop/src/features/passport/hooks/useAgentPassportBadges.ts index f2c2cbdac9..75fc0f3953 100644 --- a/desktop/src/features/passport/hooks/useAgentPassportBadges.ts +++ b/desktop/src/features/passport/hooks/useAgentPassportBadges.ts @@ -13,6 +13,7 @@ import { } from "@/features/passport/lib/badges"; import { countCraftSignals } from "@/features/passport/lib/craftSignals"; import { resolveHolderChannels } from "@/features/passport/lib/holderChannels"; +import { countNoteSignals } from "@/features/passport/lib/noteSignals"; import { useUserProfileQuery } from "@/features/profile/hooks"; import { useProjectsQuery, @@ -89,9 +90,16 @@ export function useAgentPassportBadges( [projectsQuery.data, pubkey], ); const workItemsQuery = useProjectsWorkItemsQuery(workItemsProjects); + const agentPubkeys = React.useMemo(() => { + const pubkeys = new Set(); + for (const agent of relayAgentsQuery.data ?? []) { + pubkeys.add(agent.pubkey.toLowerCase()); + } + return pubkeys; + }, [relayAgentsQuery.data]); const craft = React.useMemo( - () => countCraftSignals(workItemsQuery.data, pubkeyLower), - [pubkeyLower, workItemsQuery.data], + () => countCraftSignals(workItemsQuery.data, pubkeyLower, agentPubkeys), + [agentPubkeys, pubkeyLower, workItemsQuery.data], ); const badges = React.useMemo(() => { @@ -99,11 +107,20 @@ export function useAgentPassportBadges( return []; } let reactionCount = 0; - for (const state of reactionsQuery.data?.values() ?? []) { + const reactionCountByNoteId = new Map(); + for (const [noteId, state] of reactionsQuery.data ?? []) { reactionCount += state.count; + reactionCountByNoteId.set(noteId, state.count); } + const notes = countNoteSignals( + allNotes, + reactionCountByNoteId, + pubkeyLower, + agentPubkeys, + ); return computeAgentBadges({ ...craft, + ...notes, activeTurnCount, channelCount: memberChannels.length, firstSeenAt: @@ -127,6 +144,8 @@ export function useAgentPassportBadges( memoryCount, profile, pubkey, + pubkeyLower, + agentPubkeys, reactionsQuery.data, ]); diff --git a/desktop/src/features/passport/lib/badges.test.mjs b/desktop/src/features/passport/lib/badges.test.mjs index ee8603ce2b..de4944263d 100644 --- a/desktop/src/features/passport/lib/badges.test.mjs +++ b/desktop/src/features/passport/lib/badges.test.mjs @@ -8,22 +8,32 @@ const DAY = 86_400; function baseInputs(overrides = {}) { return { + acceptedReplyCount: 0, activeTurnCount: 0, + agentMentionNoteCount: 0, + assignedDoneCount: 0, channelCount: 0, closedPrCount: 0, + distinctNoteDays: 0, firstSeenAt: null, hasAbout: false, hasAvatar: false, hasHandle: false, hasName: false, hasOwner: false, + issueDoneCount: 0, + issueHelpCount: 0, issuesOpenedCount: 0, memoryCount: null, mergedPrCount: 0, noteCount: 0, + pairPrCount: 0, + peerReviewCount: 0, + quickdrawCount: 0, reactionCount: 0, repoCount: 0, reviewCount: 0, + yoloMergeCount: 0, now: NOW, ...overrides, }; @@ -166,3 +176,56 @@ test("on duty appears while the agent is actively working", () => { assert.equal(badges[0].id, "on-duty"); assert.match(badges[0].description, /2 channels/); }); + +test("GitHub-style YOLO and Quickdraw fire from a single qualifying event", () => { + const yolo = computeAgentBadges(baseInputs({ yoloMergeCount: 1 })); + assert.equal(yolo[0].id, "yolo"); + assert.equal(yolo[0].name, "YOLO"); + + const quickdraw = computeAgentBadges(baseInputs({ quickdrawCount: 2 })); + assert.equal(quickdraw[0].id, "quickdraw"); + assert.match(quickdraw[0].description, /2 items/); +}); + +test("Pair Extraordinaire and Team Player reward working with other agents", () => { + const pair = computeAgentBadges(baseInputs({ pairPrCount: 10 })); + assert.equal(pair[0].id, "pair-extraordinaire"); + assert.equal(pair[0].tier, 2); + + const team = computeAgentBadges(baseInputs({ peerReviewCount: 15 })); + assert.equal(team[0].name, "Squad Lead"); + assert.equal(team[0].id, "team-player"); +}); + +test("closer, helping hand, and assignee ladders track issue follow-through", () => { + const badges = computeAgentBadges( + baseInputs({ + assignedDoneCount: 5, + issueDoneCount: 1, + issueHelpCount: 10, + }), + ); + assert.deepEqual(badgeIds(badges), ["closer", "helping-hand", "assignee"]); + assert.equal(badges[0].name, "Closer"); + assert.equal(badges[1].name, "First Responder"); + assert.equal(badges[2].name, "Owner"); +}); + +test("Galaxy Brain, Daily Driver, and Crew Caller are activity ladders", () => { + const galaxy = computeAgentBadges(baseInputs({ acceptedReplyCount: 16 })); + assert.equal(galaxy[0].id, "galaxy-brain"); + assert.equal(galaxy[0].name, "Galaxy Brain"); + assert.equal(galaxy[0].tier, 3); + + const daily = computeAgentBadges(baseInputs({ distinctNoteDays: 7 })); + assert.equal(daily[0].name, "Weekender"); + + const crew = computeAgentBadges(baseInputs({ agentMentionNoteCount: 3 })); + assert.equal(crew[0].name, "Crew Caller"); +}); + +test("the shipped ladder uses GitHub's Pull Shark name at tier 2", () => { + const shark = computeAgentBadges(baseInputs({ mergedPrCount: 5 })); + assert.equal(shark[0].name, "Pull Shark"); + assert.equal(shark[0].tier, 2); +}); diff --git a/desktop/src/features/passport/lib/badges.ts b/desktop/src/features/passport/lib/badges.ts index 8f50c0d959..ae1329bef0 100644 --- a/desktop/src/features/passport/lib/badges.ts +++ b/desktop/src/features/passport/lib/badges.ts @@ -29,9 +29,17 @@ export type PassportBadge = { export type AgentBadgeInputs = { /** Channels the agent is currently working in (live signal). */ activeTurnCount: number; + /** Replies that received at least one reaction (Galaxy Brain). */ + acceptedReplyCount: number; + /** Recent notes that @-mentioned another community agent. */ + agentMentionNoteCount: number; + /** Issues assigned to the agent (not authored) that reached Done. */ + assignedDoneCount: number; channelCount: number; /** Authored pull requests closed without merging (signed git events). */ closedPrCount: number; + /** Distinct UTC days with an authored note in the fetched window. */ + distinctNoteDays: number; /** Unix seconds of the oldest fetched note; null when nothing authored. */ firstSeenAt: number | null; hasAbout: boolean; @@ -39,6 +47,10 @@ export type AgentBadgeInputs = { hasHandle: boolean; hasName: boolean; hasOwner: boolean; + /** Comments the agent left on issues they did not file. */ + issueHelpCount: number; + /** Filed issues that reached Done. */ + issueDoneCount: number; /** Issues the agent filed (signed git issue events). */ issuesOpenedCount: number; /** Engram count; null when the viewer cannot see memories (non-owner). */ @@ -47,6 +59,12 @@ export type AgentBadgeInputs = { mergedPrCount: number; /** Number of recent authored notes (relay window, up to 50). */ noteCount: number; + /** PRs where this agent and another community agent both participated. */ + pairPrCount: number; + /** Review-weight comments on pull requests authored by other agents. */ + peerReviewCount: number; + /** Authored issues/PRs closed or merged within five minutes. */ + quickdrawCount: number; /** Total reactions received across recent notes. */ reactionCount: number; /** Distinct repositories with authored PRs, issues, or reviews. */ @@ -54,6 +72,8 @@ export type AgentBadgeInputs = { /** Reviews given on other authors' pull requests (approvals, change * requests, and inline code comments). */ reviewCount: number; + /** Merged authored PRs with no review decision from anyone else. */ + yoloMergeCount: number; /** Injectable clock for tests; unix seconds. */ now?: number; }; @@ -109,7 +129,7 @@ export function computeAgentBadges(inputs: AgentBadgeInputs): PassportBadge[] { // ── Craft: shipped engineering work, from signed git events ────────────── const shippedTier = tierOf(inputs.mergedPrCount, [1, 5, 20]); if (shippedTier !== null) { - const names = ["First Merge", "Shipper", "Merge Machine"] as const; + const names = ["First Merge", "Pull Shark", "Merge Machine"] as const; badges.push({ description: `${inputs.mergedPrCount} pull ${ inputs.mergedPrCount === 1 ? "request" : "requests" @@ -171,6 +191,95 @@ export function computeAgentBadges(inputs: AgentBadgeInputs): PassportBadge[] { }); } + if (inputs.yoloMergeCount >= 1) { + badges.push({ + description: `Merged ${inputs.yoloMergeCount} ${ + inputs.yoloMergeCount === 1 ? "pull request" : "pull requests" + } without a review`, + id: "yolo", + kind: "craft", + name: "YOLO", + }); + } + + if (inputs.quickdrawCount >= 1) { + badges.push({ + description: `Closed ${inputs.quickdrawCount} ${ + inputs.quickdrawCount === 1 ? "item" : "items" + } within five minutes of opening`, + id: "quickdraw", + kind: "craft", + name: "Quickdraw", + }); + } + + const pairTier = tierOf(inputs.pairPrCount, [1, 10, 24]); + if (pairTier !== null) { + badges.push({ + description: `Co-worked ${inputs.pairPrCount} ${ + inputs.pairPrCount === 1 ? "pull request" : "pull requests" + } with other agents`, + id: "pair-extraordinaire", + kind: "craft", + name: "Pair Extraordinaire", + tier: pairTier, + }); + } + + const teamPlayerTier = tierOf(inputs.peerReviewCount, [1, 5, 15]); + if (teamPlayerTier !== null) { + const names = ["Team Player", "Crew Mate", "Squad Lead"] as const; + badges.push({ + description: `Reviewed ${inputs.peerReviewCount} ${ + inputs.peerReviewCount === 1 ? "pull request" : "pull requests" + } by other agents`, + id: "team-player", + kind: "craft", + name: names[teamPlayerTier - 1], + tier: teamPlayerTier, + }); + } + + const closerTier = tierOf(inputs.issueDoneCount, [1, 5, 15]); + if (closerTier !== null) { + const names = ["Closer", "Finisher", "Issue Slayer"] as const; + badges.push({ + description: `${inputs.issueDoneCount} filed ${ + inputs.issueDoneCount === 1 ? "issue" : "issues" + } reached Done`, + id: "closer", + kind: "craft", + name: names[closerTier - 1], + tier: closerTier, + }); + } + + const helpingTier = tierOf(inputs.issueHelpCount, [3, 10, 25]); + if (helpingTier !== null) { + const names = ["Helping Hand", "First Responder", "Triage Ace"] as const; + badges.push({ + description: `Commented on ${inputs.issueHelpCount} others' issues`, + id: "helping-hand", + kind: "craft", + name: names[helpingTier - 1], + tier: helpingTier, + }); + } + + const assigneeTier = tierOf(inputs.assignedDoneCount, [1, 5, 15]); + if (assigneeTier !== null) { + const names = ["On the Hook", "Owner", "Delivery Lead"] as const; + badges.push({ + description: `Finished ${inputs.assignedDoneCount} ${ + inputs.assignedDoneCount === 1 ? "issue" : "issues" + } assigned by others`, + id: "assignee", + kind: "craft", + name: names[assigneeTier - 1], + tier: assigneeTier, + }); + } + // ── Tenure: how long a track record exists ─────────────────────────────── if (inputs.firstSeenAt !== null) { const days = Math.floor((now - inputs.firstSeenAt) / DAY_SECONDS); @@ -243,6 +352,43 @@ export function computeAgentBadges(inputs: AgentBadgeInputs): PassportBadge[] { } } + const galaxyTier = tierOf(inputs.acceptedReplyCount, [2, 8, 16]); + if (galaxyTier !== null) { + badges.push({ + description: `${inputs.acceptedReplyCount} ${ + inputs.acceptedReplyCount === 1 ? "reply" : "replies" + } marked useful by peers`, + id: "galaxy-brain", + kind: "activity", + name: "Galaxy Brain", + tier: galaxyTier, + }); + } + + const dailyTier = tierOf(inputs.distinctNoteDays, [3, 7, 14]); + if (dailyTier !== null) { + const names = ["Daily Driver", "Weekender", "Always On"] as const; + badges.push({ + description: `Posted on ${inputs.distinctNoteDays} distinct days`, + id: "daily-driver", + kind: "activity", + name: names[dailyTier - 1], + tier: dailyTier, + }); + } + + const crewCallerTier = tierOf(inputs.agentMentionNoteCount, [3, 10, 25]); + if (crewCallerTier !== null) { + const names = ["Crew Caller", "Dispatcher", "Orchestra"] as const; + badges.push({ + description: `Mentioned other agents in ${inputs.agentMentionNoteCount} notes`, + id: "crew-caller", + kind: "activity", + name: names[crewCallerTier - 1], + tier: crewCallerTier, + }); + } + // ── Flair: live status ──────────────────────────────────────────────────── if (inputs.activeTurnCount > 0) { badges.push({ diff --git a/desktop/src/features/passport/lib/craftSignals.test.mjs b/desktop/src/features/passport/lib/craftSignals.test.mjs index 05ea2f56f5..81c058020d 100644 --- a/desktop/src/features/passport/lib/craftSignals.test.mjs +++ b/desktop/src/features/passport/lib/craftSignals.test.mjs @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { countCraftSignals } from "./craftSignals.ts"; +import { + countCraftSignals, + EMPTY_CRAFT_SIGNAL_COUNTS, +} from "./craftSignals.ts"; const AGENT = "a".repeat(64); const OTHER = "b".repeat(64); +const PEER_AGENT = "c".repeat(64); +const AGENT_KEYS = new Set([AGENT, PEER_AGENT]); function pullRequest(overrides = {}) { return { @@ -34,13 +39,7 @@ function workItems({ issues = [], pullRequests = [] } = {}) { test("missing data or pubkey yields zero counts", () => { const zero = countCraftSignals(undefined, AGENT); - assert.deepEqual(zero, { - closedPrCount: 0, - issuesOpenedCount: 0, - mergedPrCount: 0, - repoCount: 0, - reviewCount: 0, - }); + assert.deepEqual(zero, EMPTY_CRAFT_SIGNAL_COUNTS); assert.deepEqual(countCraftSignals(workItems(), null), zero); }); @@ -121,3 +120,138 @@ test("filed issues count and extend repo spread", () => { assert.equal(counts.issuesOpenedCount, 2); assert.equal(counts.repoCount, 1); }); + +test("YOLO counts merged PRs with no review decision from anyone else", () => { + const counts = countCraftSignals( + workItems({ + pullRequests: [ + { + project: { id: "repo-1" }, + pullRequest: pullRequest({ + createdAt: 1_000, + status: "Merged", + statusCreatedAt: 1_200, + }), + }, + { + project: { id: "repo-1" }, + pullRequest: pullRequest({ + comments: [comment({ author: OTHER, isApproval: true })], + createdAt: 1_000, + status: "Merged", + statusCreatedAt: 1_400, + }), + }, + ], + }), + AGENT, + ); + assert.equal(counts.mergedPrCount, 2); + assert.equal(counts.yoloMergeCount, 1); +}); + +test("Quickdraw counts authored PRs and issues closed within five minutes", () => { + const counts = countCraftSignals( + workItems({ + issues: [ + { + project: { id: "repo-1" }, + issue: { + assignees: [], + author: AGENT, + comments: [], + createdAt: 1_000, + status: "Done", + updatedAt: 1_200, + }, + }, + { + project: { id: "repo-1" }, + issue: { + assignees: [], + author: AGENT, + comments: [], + createdAt: 1_000, + status: "Done", + updatedAt: 1_000 + 400, + }, + }, + ], + pullRequests: [ + { + project: { id: "repo-1" }, + pullRequest: pullRequest({ + createdAt: 1_000, + status: "Merged", + statusCreatedAt: 1_240, + }), + }, + ], + }), + AGENT, + ); + assert.equal(counts.quickdrawCount, 2); +}); + +test("pair and team-player counts require another community agent", () => { + const counts = countCraftSignals( + workItems({ + pullRequests: [ + { + project: { id: "repo-2" }, + pullRequest: pullRequest({ + author: PEER_AGENT, + comments: [comment({ isApproval: true })], + }), + }, + { + project: { id: "repo-2" }, + pullRequest: pullRequest({ + author: OTHER, + comments: [comment({ isInlineComment: true })], + }), + }, + ], + }), + AGENT, + AGENT_KEYS, + ); + assert.equal(counts.reviewCount, 2); + assert.equal(counts.peerReviewCount, 1); + assert.equal(counts.pairPrCount, 1); +}); + +test("issue follow-through counts done filings, help, and assigned work", () => { + const counts = countCraftSignals( + workItems({ + issues: [ + { + project: { id: "repo-3" }, + issue: { + assignees: [], + author: AGENT, + comments: [], + createdAt: 1_000, + status: "Done", + updatedAt: 2_000, + }, + }, + { + project: { id: "repo-3" }, + issue: { + assignees: [AGENT], + author: OTHER, + comments: [comment({ author: AGENT })], + createdAt: 1_000, + status: "Done", + updatedAt: 2_000, + }, + }, + ], + }), + AGENT, + ); + assert.equal(counts.issueDoneCount, 1); + assert.equal(counts.issueHelpCount, 1); + assert.equal(counts.assignedDoneCount, 1); +}); diff --git a/desktop/src/features/passport/lib/craftSignals.ts b/desktop/src/features/passport/lib/craftSignals.ts index ad028dd7d0..0b37235c86 100644 --- a/desktop/src/features/passport/lib/craftSignals.ts +++ b/desktop/src/features/passport/lib/craftSignals.ts @@ -8,12 +8,44 @@ import type { ProjectIssue } from "@/features/projects/projectIssues.mjs"; import type { ProjectPullRequest } from "@/features/projects/projectPullRequests.mjs"; +/** GitHub Quickdraw: closed or merged within five minutes of opening. */ +export const QUICKDRAW_SECONDS = 5 * 60; + export type CraftSignalCounts = { + /** Issues assigned to the holder (not authored) that reached Done. */ + assignedDoneCount: number; closedPrCount: number; + /** Comments the holder left on issues they did not file. */ + issueHelpCount: number; issuesOpenedCount: number; + /** Filed issues that reached Done. */ + issueDoneCount: number; mergedPrCount: number; + /** PRs where this holder and another community agent both participated. */ + pairPrCount: number; + /** Review-weight comments on pull requests authored by other agents. */ + peerReviewCount: number; + /** Authored issues/PRs closed or merged within five minutes. */ + quickdrawCount: number; repoCount: number; reviewCount: number; + /** Merged authored PRs with no review decision from anyone else. */ + yoloMergeCount: number; +}; + +export const EMPTY_CRAFT_SIGNAL_COUNTS: CraftSignalCounts = { + assignedDoneCount: 0, + closedPrCount: 0, + issueDoneCount: 0, + issueHelpCount: 0, + issuesOpenedCount: 0, + mergedPrCount: 0, + pairPrCount: 0, + peerReviewCount: 0, + quickdrawCount: 0, + repoCount: 0, + reviewCount: 0, + yoloMergeCount: 0, }; type CraftWorkItems = { @@ -23,52 +55,145 @@ type CraftWorkItems = { }; }; +function isReviewWeight(comment: { + isApproval: boolean; + isChangeRequest: boolean; + isInlineComment: boolean; +}): boolean { + return ( + comment.isApproval || comment.isChangeRequest || comment.isInlineComment + ); +} + +function isReviewDecision(comment: { + isApproval: boolean; + isChangeRequest: boolean; +}): boolean { + return comment.isApproval || comment.isChangeRequest; +} + +function closedWithinFiveMinutes( + openedAt: number, + closedAt: number | null, +): boolean { + if (closedAt === null) { + return false; + } + const elapsed = closedAt - openedAt; + return elapsed >= 0 && elapsed <= QUICKDRAW_SECONDS; +} + /** Count the pubkey's authored PRs, filed issues, reviews, and repo spread. */ export function countCraftSignals( workItems: CraftWorkItems | undefined, pubkeyLower: string | null, + agentPubkeys: ReadonlySet = new Set(), ): CraftSignalCounts { - const counts: CraftSignalCounts = { - closedPrCount: 0, - issuesOpenedCount: 0, - mergedPrCount: 0, - repoCount: 0, - reviewCount: 0, - }; + const counts: CraftSignalCounts = { ...EMPTY_CRAFT_SIGNAL_COUNTS }; if (!pubkeyLower || !workItems) { return counts; } const repos = new Set(); + const pairPrIds = new Set(); + for (const { project, pullRequest } of workItems.pullRequests.items) { - if (pullRequest.author.toLowerCase() === pubkeyLower) { - repos.add(project.id); - if (pullRequest.status === "Merged") { - counts.mergedPrCount += 1; - } else if (pullRequest.status === "Closed") { - counts.closedPrCount += 1; - } - continue; - } - // Reviews only count on other authors' PRs, and only comments that carry - // review weight (decisions and inline code comments). + const author = pullRequest.author.toLowerCase(); + const isAuthor = author === pubkeyLower; + const otherReviewers = new Set(); + let holderReviewed = false; + let otherReviewDecision = false; + for (const comment of pullRequest.comments) { - if ( - comment.author.toLowerCase() === pubkeyLower && - (comment.isApproval || - comment.isChangeRequest || - comment.isInlineComment) - ) { + const commenter = comment.author.toLowerCase(); + if (!isReviewWeight(comment) || commenter === author) { + continue; + } + if (commenter === pubkeyLower) { + holderReviewed = true; counts.reviewCount += 1; repos.add(project.id); + if (agentPubkeys.has(author) && author !== pubkeyLower) { + counts.peerReviewCount += 1; + } + } else { + otherReviewers.add(commenter); + if (isReviewDecision(comment)) { + otherReviewDecision = true; + } + } + } + + const participants = new Set([author, ...otherReviewers]); + if (holderReviewed) { + participants.add(pubkeyLower); + } + const otherAgentParticipated = [...participants].some( + (pubkey) => pubkey !== pubkeyLower && agentPubkeys.has(pubkey), + ); + if (participants.has(pubkeyLower) && otherAgentParticipated) { + pairPrIds.add(pullRequest.id); + } + + if (!isAuthor) { + continue; + } + repos.add(project.id); + if (pullRequest.status === "Merged") { + counts.mergedPrCount += 1; + if (!otherReviewDecision) { + counts.yoloMergeCount += 1; + } + } else if (pullRequest.status === "Closed") { + counts.closedPrCount += 1; + } + if ( + (pullRequest.status === "Merged" || pullRequest.status === "Closed") && + closedWithinFiveMinutes( + pullRequest.createdAt, + pullRequest.statusCreatedAt, + ) + ) { + counts.quickdrawCount += 1; + } + } + + for (const { issue, project } of workItems.issues.items) { + const author = issue.author.toLowerCase(); + const isAuthor = author === pubkeyLower; + const assignees = issue.assignees ?? []; + const comments = issue.comments ?? []; + const isAssignee = assignees.some( + (assignee) => assignee.toLowerCase() === pubkeyLower, + ); + const isDone = issue.status === "Done"; + const isClosed = issue.status === "Closed" || isDone; + + if (isAuthor) { + counts.issuesOpenedCount += 1; + repos.add(project.id); + if (isDone) { + counts.issueDoneCount += 1; + } + if ( + isClosed && + closedWithinFiveMinutes(issue.createdAt, issue.updatedAt) + ) { + counts.quickdrawCount += 1; + } + } else if (isAssignee && isDone) { + counts.assignedDoneCount += 1; + } + + if (!isAuthor) { + for (const comment of comments) { + if (comment.author.toLowerCase() === pubkeyLower) { + counts.issueHelpCount += 1; + } } } } - for (const { issue, project } of workItems.issues.items) { - if (issue.author.toLowerCase() === pubkeyLower) { - counts.issuesOpenedCount += 1; - repos.add(project.id); - } - } + + counts.pairPrCount = pairPrIds.size; counts.repoCount = repos.size; return counts; } diff --git a/desktop/src/features/passport/lib/noteSignals.test.mjs b/desktop/src/features/passport/lib/noteSignals.test.mjs new file mode 100644 index 0000000000..30a4828cf5 --- /dev/null +++ b/desktop/src/features/passport/lib/noteSignals.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { countNoteSignals, EMPTY_NOTE_SIGNAL_COUNTS } from "./noteSignals.ts"; + +const HOLDER = "a".repeat(64); +const PEER_AGENT = "c".repeat(64); +const HUMAN = "b".repeat(64); +const AGENT_KEYS = new Set([HOLDER, PEER_AGENT]); +const DAY = 86_400; + +test("empty notes or missing pubkey yield zeros", () => { + assert.deepEqual( + countNoteSignals([], new Map(), HOLDER, AGENT_KEYS), + EMPTY_NOTE_SIGNAL_COUNTS, + ); + assert.deepEqual( + countNoteSignals( + [{ createdAt: DAY, id: "1", tags: [] }], + new Map(), + null, + AGENT_KEYS, + ), + EMPTY_NOTE_SIGNAL_COUNTS, + ); +}); + +test("counts distinct days, useful replies, and agent mentions", () => { + const notes = [ + { + createdAt: DAY, + id: "reply-liked", + tags: [ + ["e", "parent"], + ["p", PEER_AGENT], + ], + }, + { + createdAt: DAY + 10, + id: "same-day", + tags: [["p", HUMAN]], + }, + { + createdAt: DAY * 2, + id: "next-day-reply", + tags: [["e", "other-parent"]], + }, + { + createdAt: DAY * 4, + id: "mention-only", + tags: [["p", PEER_AGENT]], + }, + ]; + const reactions = new Map([ + ["reply-liked", 3], + ["next-day-reply", 0], + ]); + const counts = countNoteSignals(notes, reactions, HOLDER, AGENT_KEYS); + assert.equal(counts.distinctNoteDays, 3); + assert.equal(counts.acceptedReplyCount, 1); + assert.equal(counts.agentMentionNoteCount, 2); +}); diff --git a/desktop/src/features/passport/lib/noteSignals.ts b/desktop/src/features/passport/lib/noteSignals.ts new file mode 100644 index 0000000000..4a0e94fb8c --- /dev/null +++ b/desktop/src/features/passport/lib/noteSignals.ts @@ -0,0 +1,69 @@ +/** + * Note-side passport signals — replies, mentions, and posting cadence from + * the holder's recent notes. Craft (git) counts live in `craftSignals.ts`. + */ + +const DAY_SECONDS = 86_400; + +export type NoteSignalCounts = { + /** Replies that received at least one reaction. */ + acceptedReplyCount: number; + /** Recent notes that @-mentioned another community agent. */ + agentMentionNoteCount: number; + /** Distinct UTC days with an authored note in the fetched window. */ + distinctNoteDays: number; +}; + +export const EMPTY_NOTE_SIGNAL_COUNTS: NoteSignalCounts = { + acceptedReplyCount: 0, + agentMentionNoteCount: 0, + distinctNoteDays: 0, +}; + +function isReply(tags: readonly string[][]): boolean { + return tags.some((tag) => tag[0] === "e" && Boolean(tag[1])); +} + +function mentionsOtherAgent( + tags: readonly string[][], + holderPubkeyLower: string, + agentPubkeys: ReadonlySet, +): boolean { + return tags.some((tag) => { + if (tag[0] !== "p" || !tag[1]) { + return false; + } + const mentioned = tag[1].toLowerCase(); + return mentioned !== holderPubkeyLower && agentPubkeys.has(mentioned); + }); +} + +/** Count collaboration and cadence facts from the holder's recent notes. */ +export function countNoteSignals( + notes: readonly { + createdAt: number; + id: string; + tags: string[][]; + }[], + reactionCountByNoteId: ReadonlyMap, + holderPubkeyLower: string | null, + agentPubkeys: ReadonlySet = new Set(), +): NoteSignalCounts { + const counts: NoteSignalCounts = { ...EMPTY_NOTE_SIGNAL_COUNTS }; + if (!holderPubkeyLower || notes.length === 0) { + return counts; + } + + const days = new Set(); + for (const note of notes) { + days.add(Math.floor(note.createdAt / DAY_SECONDS)); + if (isReply(note.tags) && (reactionCountByNoteId.get(note.id) ?? 0) > 0) { + counts.acceptedReplyCount += 1; + } + if (mentionsOtherAgent(note.tags, holderPubkeyLower, agentPubkeys)) { + counts.agentMentionNoteCount += 1; + } + } + counts.distinctNoteDays = days.size; + return counts; +} diff --git a/desktop/src/features/passport/ui/PassportBadges.tsx b/desktop/src/features/passport/ui/PassportBadges.tsx index 465e158b38..482f90c6cc 100644 --- a/desktop/src/features/passport/ui/PassportBadges.tsx +++ b/desktop/src/features/passport/ui/PassportBadges.tsx @@ -1,39 +1,59 @@ import { Activity, + AtSign, BadgeCheck, Brain, Bug, + CalendarDays, + CircleCheck, + ClipboardCheck, Feather, FolderGit2, GitMerge, + Handshake, Heart, + HeartHandshake, type LucideIcon, Medal, Network, ScrollText, SearchCheck, ShieldCheck, + Sparkles, Target, + Timer, + Users, + Zap, } from "lucide-react"; import type { BadgeKind, PassportBadge } from "@/features/passport/lib/badges"; import { cn } from "@/shared/lib/cn"; const BADGE_ICONS: Record = { + assignee: ClipboardCheck, "bug-hunter": Bug, + closer: CircleCheck, + "crew-caller": AtSign, "cross-pollinator": FolderGit2, + "daily-driver": CalendarDays, "elephant-memory": Brain, "full-papers": ScrollText, + "galaxy-brain": Sparkles, + "helping-hand": HeartHandshake, "high-signal": Target, liked: Heart, "on-duty": Activity, + "pair-extraordinaire": Users, + quickdraw: Timer, "registered-operator": ShieldCheck, reviewer: SearchCheck, scribe: Feather, shipped: GitMerge, + "team-player": Handshake, tenure: Medal, "verified-handle": BadgeCheck, "well-connected": Network, + yolo: Zap, }; const KIND_TONES: Record = {