Know when it's your turn.
Developer work is spread across pull requests, reviews, CI runs, issue trackers and AI coding sessions. Most of the time, that work is in someone else's hands — a reviewer, a pipeline, an agent. Tern is a small native macOS menu bar app that tracks the state of each piece of work and stays quiet until the next action comes back to you.
Note
Tern is an actively developed personal side project. APIs, UI and architecture are still evolving.
Tern is not a notification aggregator. It models work as a stream of ownership transitions:
ME → AGENT → REVIEWER → ME → CI → ME → COMPLETE
The interesting event is not "something happened". It is "the ball is back with me".
A new commit on a pull request you're reviewing, a CI run starting, an agent reading files — these change state, but they don't need you. A requested change, a failed check, or an agent waiting for input do. Tern is quiet by default: it surfaces a workstream only when the turn is yours and the reason is new.
- Menu bar app. The Tern mark sits in the menu bar. When something needs you, the ball appears next to the path with a count beside it. Clicking it opens a SwiftUI panel.
- Workstreams, not events. A Plane work item, its pull request and the Claude Code sessions working on it are linked into one workstream.
- Ownership detection. For each workstream Tern works out who holds the next action: you, an agent, a reviewer, CI, someone external, or nobody.
- Attention ranking. Work that needs you is ranked by urgency and by how much the repository matters to you (primary, normal, low priority, muted). This is a ranking preference, separate from Personal/Professional contexts.
- Take me there. Tern doesn't just tell you when it's your turn; it can take you directly to where the next action happens. Items that are yours get one button: Open PR, Open in Plane or Join meeting, plus the other destination when a PR and its Plane item are linked. Buttons only use links GitHub, Plane or the calendar event provided, and pressing one changes nothing in Tern; the item updates when the source system reports what you did. Agent sessions have no button, since answering Claude happens in its own terminal or editor.
- Snooze. Right-click something that needs you and snooze it for 30 minutes, 1 hour, 3 hours or until tomorrow morning (9:00). It leaves Needs you, the badge and notifications until then, without changing the work itself; anything that changed meanwhile is notified once when the snooze ends. Snoozes are per context and listed in a small Snoozed section with Unsnooze.
- Panel sections. Needs you, Waiting, Active, Your other work, Done today and Idle.
- Personal / Professional contexts. Work is separated into two contexts; only the active one counts toward the queue and badge.
- Transition-based alerts. Tern decides whether a change is worth surfacing by comparing it against what you were last shown, and marks genuinely new items in the panel.
- Meeting awareness. A meeting from a calendar you classified becomes a candidate for Needs you 15 minutes before it starts, ranked with your work on one scale, with a Join action when the event carries a call link.
- Integrations. GitHub (through the
ghCLI), Plane, Claude Code hooks, and macOS Calendar (read-only).
Screenshots coming soon.
Event GitHub sync · Plane sync · Claude Code hook
↓
Normalize integration-specific payload → WorkEvent
↓
Workstream link by PR, branch, session, Plane identifier
↓
State active · waiting · blocked · needs attention · complete
↓
Ownership me · agent · reviewer · CI · external · none
↓
Attention silent · low · medium · high · urgent
↓
Next action "Address requested changes", "Answer Claude", …
↓
Surface panel, badge, "New" marker
Raw events never generate alerts directly. Every decision is derived from the workstream's full event history, so the same history always yields the same answer regardless of the order events arrived in. A change is surfaced only if the turn is yours at medium attention or above, and it differs from what you were last shown: the ball came back to you, it got more urgent, or a newer event caused it.
Tern keeps four ideas separate:
| Concept | Question it answers |
|---|---|
| State | Where is this work in its lifecycle? |
| Ownership | Who holds the next action? |
| Attention | How loudly should it claim you? |
| Priority | Among everything that needs you, what comes first? |
Two examples:
- Approved and ready to merge. If your team merges through a lead — signalled by a configurable label such as
ready to merge— the merge is theirs. Tern shows the pull request as waiting on them rather than giving you a task. - Changes pushed after review. You pushed fixes, so the turn is technically yours (re-request review), but it isn't worth an interruption. It goes under Your other work instead of Needs you.
Every surfaced item carries a reason (review requested, CI failed, agent needs input, …), so Tern can always say why something is in front of you.
Personal and Professional are a domain-level split, not a view filter. The active context determines what takes part in the attention queue, ranking, badge and notifications; switching recalculates all of it. Work in the other context keeps syncing quietly and is waiting in its own queue when you switch back.
| Personal | Professional |
|---|---|
| Personal GitHub repositories | Work GitHub organizations and repositories |
| Agent sessions in personal repos | Plane work items |
| Calendars marked Personal | Agent sessions in work repos |
| — | Calendars marked Professional |
Nothing is inferred. You classify a GitHub owner (or a single repository, which overrides its owner) once by right-clicking it in the panel; unknown repositories are intentionally unclassified and appear in neither context — only in a small Unclassified list — until you do. Plane is professional-only; a Plane item linked to a repository you marked Personal is a conflict and stays unclassified. Calendars are classified one by one under Calendar at the bottom of the panel; an unclassified calendar is never read.
GitHub — Tern runs your installed, authenticated gh CLI to read pull requests you authored and reviews requested from you. Authentication stays inside gh: Tern never asks for, reads or stores a GitHub token.
Plane — Read-only sync of open work items assigned to you, from Plane Cloud or a self-hosted instance. You connect with a workspace and a personal access token, which is stored in the macOS login Keychain. Plane items are linked to pull requests when their identifier appears in the branch name, title or body.
Claude Code — A small helper, tern-hook, is bundled inside Tern.app and registered as a Claude Code command hook. It forwards session lifecycle events (started, needs input, finished, failed) to Tern through a local tern:// URL. Only the fields Tern needs are forwarded — never prompts, responses, transcripts or tool input. Tern normalizes them into the same work events as every other integration.
Calendar — Read-only access to the calendars on your Mac through EventKit, granted only when you press Allow access. Tern reads upcoming meetings from the calendars you marked Personal or Professional and keeps them in memory only: it persists the classification and which meeting alerts it has shown, never titles, notes, attendees or links. A meeting is not a workstream; it is its own short-lived source of attention that goes through the same notification policy. All-day events and meetings you declined never claim attention. The Join action appears only when the event itself carries a link to a known call service (Zoom, Meet, Teams, …).
Tern runs entirely on your Mac and has no server of its own. It reuses authentication you already have (gh) or keeps credentials in the Keychain (Plane), and stores its state as a JSON file in ~/Library/Application Support/Tern/. The only network traffic is Tern's own calls to GitHub (via gh) and to your Plane instance.
The mark is a path that rises, turns and comes back, with a ball where it lands: the work went around, and now it's your turn. One accent colour, coral, means "your turn" and nothing else. Errors are crimson, and everything that isn't yours stays neutral. The UI uses system fonts, with SF Mono for identifiers. docs/BRAND.md has the full system and usage rules.
Tern/
├── App/ entry point, AppModel (panel state), URL routing
├── Domain/ workstreams, events, ownership, attention, contexts, workflow rules
├── Engine/ attention engine, ownership resolver, priority model, notification policy
├── Services/ IngestionService — the single path from events to workstreams
├── Integrations/ GitHub, Plane, Claude Code: sync, API models, normalizers
├── Persistence/ JSON state store, Keychain
├── UI/ SwiftUI panel, rows, connection views
└── Debug/ mock scenarios and diagnostics (Debug builds only)
TernHook/ the tern-hook command-line helper
TernTests/ Swift Testing suite
scripts/ install and hook-management scripts
Integrations only produce normalized events. IngestionService deduplicates them, links them to workstreams, and asks the engine — pure, deterministic functions over a workstream's history — for its current state, owner, attention and next action.
Requirements: macOS 15+, Xcode 16+ (Swift 6). Optional: an authenticated gh CLI, a Plane account, Claude Code.
open Tern.xcodeprojRun the Tern scheme. Debug builds keep their own bundle ID, URL scheme, defaults and Keychain items, so they never touch an installed copy. They start in memory with a mock scenario; set TERN_MOCK=0 in the scheme's environment to start empty, or TERN_PERSIST=1 to start without the mock and keep state across launches in ~/Library/Application Support/Tern/state-debug.json (never the installed app's state.json).
Build Release from the command line:
xcodebuild -project Tern.xcodeproj -scheme Tern -configuration Release buildBuild, install to /Applications, register the Claude Code hooks and launch:
scripts/install-tern.shRun it again to upgrade. --help lists the options (--no-hooks, --dest, --ad-hoc, …). Hooks can be managed separately:
scripts/install-claude-hooks.sh statusBuild a distributable DMG without opening Xcode:
scripts/release.shThis builds the Tern scheme in Release, then packages Tern.app into dist/Tern-<version>.dmg (the version is read from the Xcode project, so 0.1.0 today becomes 0.1.1 automatically after a version bump). The DMG also contains an /Applications shortcut for drag-install.
Signing note: Release currently uses the project's existing ad-hoc signing configuration. A distributable Developer ID + notarization workflow will extend scripts/release.sh later; it is not part of this step.
Workflow rules (such as the merge hand-off label) are read from user defaults:
defaults write so.plane.tern workflow.rules '{"mergeHandOffs":[{"label":"ready to merge","mergedBy":"manager"}]}'The project includes a comprehensive automated test suite written with Swift Testing, covering normalization, ownership, attention, notification decisions, determinism, persistence and the Claude Code hook path end to end.
xcodebuild test -project Tern.xcodeproj -scheme Tern -destination 'platform=macOS'A manual smoke test for the Calendar integration:
- Run the Debug build from Xcode with
TERN_PERSIST=1in the scheme's environment (so step 11 can check a relaunch). - Under Calendar at the bottom of the panel, press Allow access… and grant it. Ad-hoc-signed Debug builds can lose the grant after a rebuild; grant again if Access off appears.
- Open the calendar list and mark one calendar Personal.
- Mark another calendar Professional.
- In Calendar, create a meeting about 16 minutes from now in the Professional calendar, with a Google Meet or Zoom link in its URL, location or notes.
- Switch to Professional. The meeting shows under Up next with a countdown and "Needs you from ".
- At that time (15 minutes before the start) it moves to Needs you, marked New, with "Inside the 15-minute preparation window" and Join meeting; the badge counts it.
- Switch to Personal: it is gone from the panel and the badge. A Personal-calendar meeting behaves the same way the other way round.
- Back in Professional, click Join meeting: the call link opens. A meeting without a link shows Prepare for meeting and nothing to click.
- Switch contexts back and forth: no second New alert for the same meeting.
- Quit and relaunch: the meeting is still in Needs you, without a new alert.
- After the meeting starts it leaves Needs you (shown as in progress under Up next), and after it ends it disappears.
Ideas, not promises:
- Calendar awareness — hold back interruptions during meetings
- Richer workstream context and history in the panel
- More developer workflow integrations
- Clearer explanations of why something is or isn't surfaced