Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ Add that to your shell profile.

### Keyboard-native panel (macOS)

If you'd rather not click banners with the mouse, stack-nudge runs a small floating panel that you summon with a hotkey. It has five tabs **Events**, **Sessions**, **Usage**, **Tickets**, and **Settings** and is fully keyboard-driven.
If you'd rather not click banners with the mouse, stack-nudge runs a small floating panel that you summon with a hotkey. It has five tabs (**Events**, **Sessions**, **Usage**, **Outcomes**, and **Settings**) and is fully keyboard-driven.

The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners, toggle **Settings → Banner notifications** off (panel-only mode).

Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Tickets), `Cmd+5` (Settings)or click them. Banner and panel can run together, alone, or both off the sound and voice still fire as passive signals.
Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Outcomes), `Cmd+5` (Settings), or click them. Banner and panel can run together, alone, or both off; the sound and voice still fire as passive signals.

#### Events tab

Expand Down Expand Up @@ -220,20 +220,37 @@ The hotkey row records live: press `⏎` on it, press the new combo, and stack-n

All Settings choices persist to `~/.stack-nudge/config` (a `KEY=value` text file). You don't need to edit it directly — Settings is the source of truth — but it's there for backup/sync or scripted setup.

### Tickets (usage → outcomes)
### Outcomes (usage → shipped)

The **Tickets** tab (`⌘4`) rolls every captured agent session up **by ticket** — derived from your branch naming (`eng-123/…` → `ENG-123`), falling back to the branch when there's no ticket — and shows the tokens spent, files changed, and the live git outcome: *needs-review → committed → pushed → merged*. Select rows with `↑ ↓`, open the ticket or PR with `⏎`, dismiss one with `⌫`.
The **Outcomes** tab (`⌘4`) is two views of one dataset. It opens on the **Overview** (the aggregate); press `→` to carousel into the per-ticket **Tickets** list, `←` to step back.

#### Overview

The spend-to-outcome picture over a trailing window, so you see not just *what* your agents did but *what share of it shipped*.

- **Shipped share**: the headline. Of the effort spent in the window, how much sits on work that merged or pushed, versus in-flight, versus **abandoned** (committed / needs-review work that's gone quiet for over 14 days).
- **Spend bar**: one bar partitioning tokens across those three buckets, coloured shipped (green) / in-flight (blue) / abandoned (orange).
- **Top tickets by spend**: the heaviest tickets (or unticketed repos) in the window, each with its dominant outcome. Click a row to open the ticket (when `STACKNUDGE_TICKET_URL` is set) or its PR.
- **Agents**: the token mix per agent alongside each agent's **shipped share**, so you can see whose work actually merges. Plus the model mix.

The Overview is a scroll page: `↑ ↓` scroll, `⌘↑ ⌘↓` jump to top/bottom, `W` cycles the trailing window (24h / 7d / 30d / 90d; 90d is the ledger's own retention ceiling). Because "abandoned" needs 14 days of quiet, it only appears on the 30d and 90d windows. It's token-only today; per-model dollar cost and reclaimed-time are planned follow-ups.

#### Tickets

Every captured agent session rolled up **by ticket**, derived from your branch naming (`eng-123/…` → `ENG-123`), falling back to the branch when there's no ticket. Shows the tokens spent, files changed, and the live git outcome: *needs-review → committed → pushed → merged*. `↑ ↓` select rows, `⏎` opens the ticket or PR, `⌫` dismisses one, `←` steps back to the Overview.

The two panes agree by construction: both read the same per-branch outcome and PR state, so a squash-merged branch reads as shipped in each.

Opt-in GitHub linking adds real **PR + CI status** (so even squash-merged work reads as *merged*). Turn it on in Settings → Tickets → **GitHub PR links**, then sign in via the in-panel device flow (no `gh` needed). Config keys (all optional; the toggles in Settings write the same file):

| Key | What it does |
|-----|--------------|
| `STACKNUDGE_GITHUB` | `true` to enable GitHub PR/CI linking (off by default) |
| `STACKNUDGE_GITHUB_CLIENT_ID` | Override the embedded OAuth app Client ID (rarely needed) |
| `STACKNUDGE_TICKET_URL` | Deep-link template for ticket rows, e.g. `https://linear.app/acme/issue/{key}` `{key}` is replaced with the ticket |
| `STACKNUDGE_HIDE_SHIPPED` | `true` to drop groups once their PR reads merged, keeping the tab on in-flight work |
| `STACKNUDGE_TICKET_URL` | Deep-link template for ticket rows, e.g. `https://linear.app/acme/issue/{key}`; `{key}` is replaced with the ticket |
| `STACKNUDGE_HIDE_SHIPPED` | `true` to drop groups once their PR reads merged, keeping the list on in-flight work |

**Nothing showing up?** A session is recorded when an agent's turn ends *inside a git repo*, and only if the hook payload carries a session id. The tab's empty state names which of those failed; `~/.stack-nudge/app.log` has a line per dropped turn. The usual cause is an installed hook script older than the app, since updates swap the `.app` alone: the app repairs that on launch, and Settings warns in the footer if the rewrite couldn't be applied. To fix it by hand:
**Nothing showing up?** A session is recorded when an agent's turn ends *inside a git repo*, and only if the hook payload carries a session id. The Tickets empty state names which of those failed; `~/.stack-nudge/app.log` has a line per dropped turn. The usual cause is an installed hook script older than the app, since updates swap the `.app` alone: the app repairs that on launch, and Settings warns in the footer if the rewrite couldn't be applied. To fix it by hand:

```bash
grep -c stack-nudge-version ~/.stack-nudge/notify.sh # 0 means the script predates v1.26
Expand Down
247 changes: 247 additions & 0 deletions Tests/StackNudgePanelCoreTests/InsightsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import XCTest

@testable import StackNudgePanelCore

// Insights.summarize is the pure spend-to-outcome rollup behind the Insights
// tab. These pin the bucket partition (shipped / abandoned / in-flight), the
// shipped-share math, the staleness cutoff that defines abandoned, PR state
// superseding the local git heuristic, the agent/model mixes, and the trailing
// window boundary.
final class InsightsTests: XCTestCase {

private let day: TimeInterval = 86_400
private let now = Date(timeIntervalSince1970: 1_700_000_000)

private func key(_ repoRoot: String?, _ branch: String?) -> String {
PanelNav.outcomeKey(repoRoot, branch)
}

private func record(id: String,
agent: String = "claude",
repoRoot: String? = "/work/repo",
branch: String? = "feat/x",
ticket: String? = nil,
model: String? = nil,
tokens: Int? = 100,
updated: Date) -> HandoffRecord {
HandoffRecord(
id: id, agent: agent, repoRoot: repoRoot, branch: branch, ticket: ticket,
model: model, contextTokens: tokens, headCommit: nil, filesChanged: nil,
insertions: nil, deletions: nil, createdAt: updated, updatedAt: updated)
}

private func pr(_ state: PRState) -> PullRequestInfo {
PullRequestInfo(number: 1, url: "https://example/pr/1", state: state, isDraft: false, ci: nil)
}

func test_partitionsTokensByBucket_shippedIsMergedPlusPushed() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", tokens: 100, updated: yesterday),
record(id: "b", branch: "b2", tokens: 200, updated: yesterday),
record(id: "c", branch: "b3", tokens: 50, updated: yesterday),
],
outcomeByBranch: [
key("/work/repo", "b1"): .merged,
key("/work/repo", "b2"): .pushed,
key("/work/repo", "b3"): .needsReview,
],
pullRequestByBranch: [:],
now: now, window: 7 * day)

XCTAssertEqual(actual.totalTokens, 350)
XCTAssertEqual(actual.tokensByBucket[.shipped], 300)
XCTAssertEqual(actual.tokensByBucket[.inFlight], 50) // recent needs-review, not abandoned
XCTAssertNil(actual.tokensByBucket[.abandoned])
XCTAssertEqual(actual.shippedShare, 300.0 / 350.0, accuracy: 0.0001)
XCTAssertEqual(actual.tokensByStatus[.merged], 100)
XCTAssertEqual(actual.tokensByStatus[.pushed], 200)
XCTAssertEqual(actual.tokensByStatus[.needsReview], 50)
}

func test_shippedShare_zeroWhenNothingShipped_oneWhenAllShipped() {
let yesterday = now.addingTimeInterval(-day)

let nothing = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: 100, updated: yesterday)],
outcomeByBranch: [key("/work/repo", "b1"): .needsReview],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(nothing.shippedShare, 0)

let everything = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: 100, updated: yesterday)],
outcomeByBranch: [key("/work/repo", "b1"): .merged],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(everything.shippedShare, 1)
}

func test_abandoned_onlyWhenUnshippedBranchGoneQuietPastCutoff() {
let quiet30d = now.addingTimeInterval(-30 * day)
let abandoned = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: 100, updated: quiet30d)],
outcomeByBranch: [key("/work/repo", "b1"): .committed],
pullRequestByBranch: [:], now: now, window: 90 * day) // window wider than the 14d cutoff
XCTAssertEqual(abandoned.tokensByBucket[.abandoned], 100)
XCTAssertNil(abandoned.tokensByBucket[.inFlight])

let recent2d = now.addingTimeInterval(-2 * day)
let inFlight = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: 100, updated: recent2d)],
outcomeByBranch: [key("/work/repo", "b1"): .committed],
pullRequestByBranch: [:], now: now, window: 90 * day)
XCTAssertEqual(inFlight.tokensByBucket[.inFlight], 100)
XCTAssertNil(inFlight.tokensByBucket[.abandoned])
}

func test_prStateSupersedesLocal_squashMergeReadsAsShipped() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: 100, updated: yesterday)],
outcomeByBranch: [key("/work/repo", "b1"): .committed], // local: commits not on base
pullRequestByBranch: [key("/work/repo", "b1"): pr(.merged)], // but the PR squash-merged
now: now, window: 7 * day)
XCTAssertEqual(actual.tokensByBucket[.shipped], 100)
XCTAssertEqual(actual.tokensByStatus[.merged], 100)
XCTAssertNil(actual.tokensByStatus[.committed])
}

func test_agentMix_canonicalized() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", agent: "cursor", branch: "b1", tokens: 100, updated: yesterday),
record(id: "b", agent: "codex", branch: "b2", tokens: 40, updated: yesterday),
],
outcomeByBranch: [:], pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.tokensByAgent["claude"], 100) // cursor canonicalizes to claude
XCTAssertEqual(actual.tokensByAgent["codex"], 40)
}

func test_modelMix_keyedByRawId() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", model: "claude-opus-4-8", tokens: 100, updated: yesterday),
record(id: "b", branch: "b2", model: "claude-opus-4-8", tokens: 30, updated: yesterday),
record(id: "c", branch: "b3", model: "gpt-5-codex", tokens: 20, updated: yesterday),
],
outcomeByBranch: [:], pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.tokensByModel["claude-opus-4-8"], 130)
XCTAssertEqual(actual.tokensByModel["gpt-5-codex"], 20)
}

func test_window_excludesOlderThanWindow_includesEdge() {
let insideWindow = now.addingTimeInterval(-3 * day)
let onEdge = now.addingTimeInterval(-7 * day) // exactly at windowStart, inclusive
let outsideWindow = now.addingTimeInterval(-10 * day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", tokens: 100, updated: insideWindow),
record(id: "b", branch: "b2", tokens: 10, updated: onEdge),
record(id: "c", branch: "b3", tokens: 999, updated: outsideWindow),
],
outcomeByBranch: [:], pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.totalTokens, 110)
XCTAssertEqual(actual.sessionCount, 2)
}

func test_emptyRecords_yieldsEmptySummary() {
let actual = Insights.summarize(
records: [], outcomeByBranch: [:], pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.totalTokens, 0)
XCTAssertEqual(actual.sessionCount, 0)
XCTAssertEqual(actual.ticketCount, 0)
XCTAssertEqual(actual.shippedShare, 0)
XCTAssertTrue(actual.tokensByBucket.isEmpty)
}

func test_nilTokens_countsSessionButZeroTokens() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [record(id: "a", branch: "b1", tokens: nil, updated: yesterday)],
outcomeByBranch: [key("/work/repo", "b1"): .merged],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.sessionCount, 1)
XCTAssertEqual(actual.totalTokens, 0)
}

func test_topTickets_rankedByTokensWithDominantStatus() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", ticket: "ENG-1", tokens: 500, updated: yesterday),
record(id: "b", branch: "b2", ticket: "ENG-2", tokens: 100, updated: yesterday),
],
outcomeByBranch: [
key("/work/repo", "b1"): .merged,
key("/work/repo", "b2"): .needsReview,
],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.topTickets.map(\.label), ["ENG-1", "ENG-2"])
XCTAssertEqual(actual.topTickets.first?.status, .merged)
XCTAssertEqual(actual.topTickets.first?.bucket, .shipped)
}

func test_topTickets_dominantStatusPrefersMostShipped() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", ticket: "ENG-1", tokens: 100, updated: yesterday),
record(id: "b", branch: "b2", ticket: "ENG-1", tokens: 100, updated: yesterday),
],
outcomeByBranch: [
key("/work/repo", "b1"): .needsReview,
key("/work/repo", "b2"): .merged,
],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.topTickets.count, 1)
XCTAssertEqual(actual.topTickets.first?.tokens, 200)
XCTAssertEqual(actual.topTickets.first?.status, .merged) // merged outranks needs-review
}

func test_topTickets_prSupersedesLocalAndCarriesURL() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [record(id: "a", branch: "b1", ticket: "ENG-1", tokens: 100, updated: yesterday)],
outcomeByBranch: [key("/work/repo", "b1"): .committed],
pullRequestByBranch: [key("/work/repo", "b1"): pr(.merged)],
now: now, window: 7 * day)
XCTAssertEqual(actual.topTickets.first?.status, .merged)
XCTAssertEqual(actual.topTickets.first?.bucket, .shipped)
XCTAssertEqual(actual.topTickets.first?.prURL, "https://example/pr/1")
}

func test_shippedTokensByAgent_countsOnlyShippedBuckets() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", agent: "claude", branch: "b1", tokens: 100, updated: yesterday),
record(id: "b", agent: "claude", branch: "b2", tokens: 100, updated: yesterday),
record(id: "c", agent: "codex", branch: "b3", tokens: 50, updated: yesterday),
],
outcomeByBranch: [
key("/work/repo", "b1"): .merged,
key("/work/repo", "b2"): .needsReview,
key("/work/repo", "b3"): .pushed,
],
pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.tokensByAgent["claude"], 200)
XCTAssertEqual(actual.shippedTokensByAgent["claude"], 100) // only the merged branch
XCTAssertEqual(actual.tokensByAgent["codex"], 50)
XCTAssertEqual(actual.shippedTokensByAgent["codex"], 50) // pushed counts as shipped
}

func test_ticketCount_distinctTicketsTouched() {
let yesterday = now.addingTimeInterval(-day)
let actual = Insights.summarize(
records: [
record(id: "a", branch: "b1", ticket: "ENG-1", tokens: 10, updated: yesterday),
record(id: "b", branch: "b2", ticket: "ENG-1", tokens: 10, updated: yesterday),
record(id: "c", branch: "b3", ticket: "ENG-2", tokens: 10, updated: yesterday),
record(id: "d", branch: "feat/x", ticket: nil, tokens: 10, updated: yesterday),
],
outcomeByBranch: [:], pullRequestByBranch: [:], now: now, window: 7 * day)
XCTAssertEqual(actual.ticketCount, 2)
}
}
Loading