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
6 changes: 3 additions & 3 deletions skills/breeze/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: breeze
description: Operate the `first-tree breeze` CLI — a proposal/inbox agent that turns GitHub notifications into a live Claude Code statusline, a browsable inbox, an activity feed, and scheduled background work. Use whenever you need to run, start, stop, inspect, poll, or debug the breeze daemon; view or respond to GitHub notifications from the terminal; or wire up the breeze statusline hook.
description: Operate the `first-tree breeze` CLI — a proposal/inbox agent that turns explicit GitHub mentions and review requests into a live Claude Code statusline, a browsable inbox, an activity feed, and scheduled background work. Use whenever you need to run, start, stop, inspect, poll, or debug the breeze daemon; view or respond to GitHub-triggered breeze work from the terminal; or wire up the breeze statusline hook.
---

# Breeze — Operational Skill
Expand All @@ -26,7 +26,7 @@ and safe to re-run.

## Core Concepts

- **Inbox** — the local store of GitHub notifications, under `~/.breeze/`.
- **Inbox** — the local store of explicit GitHub mentions and review requests, under `~/.breeze/`.
- **Daemon** — a long-running broker process that polls GitHub, keeps the
inbox fresh, dispatches work to per-task agent runners, and serves a
local HTTP/SSE endpoint on `127.0.0.1:7878` for the dashboard.
Expand All @@ -47,7 +47,7 @@ and safe to re-run.
| `first-tree breeze status` | Print the daemon lock + runtime/status.env |
| `first-tree breeze doctor` | One-screen diagnostic of the local install |
| `first-tree breeze watch` | Live TUI: status board + activity feed |
| `first-tree breeze poll` | Poll GitHub notifications once (no daemon required) |
| `first-tree breeze poll` | Poll explicit GitHub mentions and review requests once (no daemon required) |

### Advanced (agents, debugging)

Expand Down
8 changes: 4 additions & 4 deletions src/products/breeze/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# `first-tree breeze`

Local daemon that takes over your `gh` login and turns GitHub notifications
(PRs, comments, discussions, issues) into a triaged, optionally auto-handled
inbox. Drives a Claude Code statusline, an SSE dashboard, and scheduled
background work.
Local daemon that takes over your `gh` login and turns explicit GitHub review
requests and direct mentions into a triaged, optionally auto-handled inbox.
Drives a Claude Code statusline, an SSE dashboard, and scheduled background
work.

## What's In This Directory

Expand Down
9 changes: 5 additions & 4 deletions src/products/breeze/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import { join } from "node:path";

export const BREEZE_USAGE = `usage: first-tree breeze <command>

Breeze is the proposal/inbox agent. It polls GitHub notifications,
keeps a local inbox under \`~/.breeze/\`, and dispatches work to
per-task agent runners.
Breeze is the proposal/inbox agent. It polls explicit GitHub review
requests and direct mentions, keeps a local inbox under \`~/.breeze/\`,
and dispatches work to per-task agent runners.

Primary commands (start here):
install Run the first-run setup (creates config.yaml, then
Expand All @@ -34,7 +34,8 @@ Primary commands (start here):
status Print daemon lock + runtime/status.env
doctor Diagnose the local install
watch Live TUI: status board + activity feed
poll Poll GitHub notifications once (no daemon required)
poll Poll explicit GitHub review requests and mentions
once (no daemon required)

Advanced commands (for agents or debugging):
run, daemon Run the broker loop in the foreground.
Expand Down
10 changes: 5 additions & 5 deletions src/products/breeze/engine/commands/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { loadBreezeConfig } from "../runtime/config.js";
import { GhClient, GhExecError } from "../runtime/gh.js";
import { resolveBreezePaths } from "../runtime/paths.js";
import { updateInbox } from "../runtime/store.js";
import { shouldProcessReason } from "../runtime/task-kind.js";
import {
type GhState,
type Inbox,
Expand Down Expand Up @@ -96,11 +97,8 @@ function priorityForReason(reason: string): number {
case "review_requested":
return 1;
case "mention":
case "team_mention":
return 2;
case "assign":
return 3;
case "participating":
return 4;
default:
return 5;
}
Expand Down Expand Up @@ -135,7 +133,8 @@ function htmlUrlFor(
/**
* Parse raw `gh api /notifications` JSON response (array of notifications,
* or concatenated arrays when `--paginate` is used) into unclassified entries.
* Filters out CheckSuite / Commit subjects (fetcher.rs:15).
* Filters out CheckSuite / Commit subjects and any notification reason that
* is not an explicit mention or review request.
*/
export function parseNotifications(
rawJsonPages: readonly string[],
Expand All @@ -162,6 +161,7 @@ export function parseNotifications(
const repo = item.repository?.full_name ?? "";
if (!repo) continue;
const reason = item.reason ?? "";
if (!shouldProcessReason(reason)) continue;
const title = item.subject?.title ?? "";
const url = item.subject?.url ?? "";
const lastActor = item.subject?.latest_comment_url ?? url ?? "";
Expand Down
2 changes: 1 addition & 1 deletion src/products/breeze/engine/daemon/candidate-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface CandidateLoopOptions {
pollIntervalSec: number;
/** Max items per `gh search` call. Rust default: 10. */
searchLimit: number;
/** Include the search-bucket queries (review_requests / assigned). */
/** Include the search-bucket query for direct review requests. */
includeSearch: boolean;
/** Lookback window for notifications, seconds. Rust default: 24h. */
lookbackSecs: number;
Expand Down
176 changes: 2 additions & 174 deletions src/products/breeze/engine/daemon/gh-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
* Phase 4: TS port of `gh.rs`.
*
* `GhClient` wraps a `GhExecutor` with the GitHub-specific query set
* (notifications, direct review requests, required-review backlog,
* assigned issues/PRs) and the snapshot-hydration path
* (notifications, direct review requests) and the snapshot-hydration path
* (`writeTaskSnapshot`). Rate limiting + write-cooldown are handled
* entirely by the executor; this module only builds argv.
*
Expand All @@ -27,9 +26,7 @@ import {
type SearchScope,
} from "../runtime/repo-filter.js";
import {
buildAssignedCandidate,
buildNotificationCandidate,
buildRequiredReviewCandidate,
buildReviewRequestCandidate,
taskIssueNumber,
taskPrNumber,
Expand Down Expand Up @@ -170,157 +167,6 @@ export class GhClient {
return deduplicate(tasks);
}

/** `gh search issues --assignee=@me --include-prs`. */
async assignedItems(limit: number): Promise<TaskCandidate[]> {
const jq =
'.[] | [(.repository.nameWithOwner // ""), ((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isPullRequest then "1" else "0" end)] | @tsv';
const tasks: TaskCandidate[] = [];
for (const scope of searchScopesFor(this.repoFilter)) {
const stdout = await this.runChecked(
"search assigned items",
withSearchScope(
[
"search",
"issues",
"--assignee=@me",
"--state",
"open",
"--include-prs",
"--limit",
String(limit),
"--json",
"number,title,url,updatedAt,repository,isPullRequest",
"--jq",
jq,
],
scope,
),
"search",
);
for (const line of stdout.split("\n")) {
if (line.trim().length === 0) continue;
const fields = parseTsvLine(line);
if (fields.length < 6) continue;
const number = Number.parseInt(fields[1], 10) || 0;
tasks.push(
buildAssignedCandidate({
repo: fields[0],
number,
title: fields[2],
webUrl: fields[3],
updatedAt: fields[4],
isPullRequest: fields[5] === "1",
}),
);
}
}
return deduplicate(tasks);
}

/**
* Recovery path for review backlog. This complements
* `--review-requested=@me`: when breeze was offline, a PR may still be
* awaiting review even though the explicit reviewer-request signal is no
* longer visible. Exact repo scopes use `gh pr list` for fresher data;
* owner/all scopes fall back to `gh search prs --review required`.
*/
async requiredReviewBacklog(limit: number): Promise<TaskCandidate[]> {
const tasks: TaskCandidate[] = [];
const repoListJq =
'.[] | [((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isDraft then "1" else "0" end), (.reviewDecision // "")] | @tsv';
for (const repo of this.repoFilter.repos()) {
const stdout = await this.runChecked(
"list required-review backlog",
[
"pr",
"list",
"--repo",
repo,
"--state",
"open",
"--search",
"review:required sort:updated-desc",
"--limit",
String(limit),
"--json",
"number,title,url,updatedAt,isDraft,reviewDecision",
"--jq",
repoListJq,
],
"core",
);
for (const line of stdout.split("\n")) {
if (line.trim().length === 0) continue;
const fields = parseTsvLine(line);
if (fields.length < 6) continue;
const number = Number.parseInt(fields[0], 10) || 0;
if (fields[4] === "1") continue;
if (fields[5] !== "REVIEW_REQUIRED") continue;
tasks.push(
buildRequiredReviewCandidate({
repo,
number,
title: fields[1],
webUrl: fields[2],
updatedAt: fields[3],
}),
);
}
}

const searchScopes: SearchScope[] = this.repoFilter.isEmpty()
? [{ kind: "all" }]
: this.repoFilter.owners().map((owner) => ({ kind: "owner", owner }));
const searchJq =
'.[] | [(.repository.nameWithOwner // ""), ((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isDraft then "1" else "0" end)] | @tsv';
for (const scope of searchScopes) {
const stdout = await this.runChecked(
"search required-review backlog",
withSearchScope(
[
"search",
"prs",
"--review",
"required",
"--state",
"open",
"--sort",
"updated",
"--order",
"desc",
"--limit",
String(limit),
"--json",
"number,title,url,updatedAt,repository,isDraft",
"--jq",
searchJq,
],
scope,
),
"search",
);
for (const line of stdout.split("\n")) {
if (line.trim().length === 0) continue;
const fields = parseTsvLine(line);
if (fields.length < 6) continue;
const repo = fields[0];
const number = Number.parseInt(fields[1], 10) || 0;
if (fields[5] === "1") continue;
tasks.push(
buildRequiredReviewCandidate({
repo,
number,
title: fields[2],
webUrl: fields[3],
updatedAt: fields[4],
}),
);
}
}

return deduplicate(tasks);
}

/** Last comment on `api_url`'s thread (empty api_url → null). */
async latestCommentActivity(apiUrl: string): Promise<ThreadActivity | null> {
if (apiUrl.trim().length === 0) return null;
Expand Down Expand Up @@ -424,7 +270,7 @@ export class GhClient {

/**
* Top-level candidate producer used by the dispatcher. Runs the
* notification poll and (optionally) the three search/list queries,
* notification poll and (optionally) the direct review-request search,
* aggregates, de-dups by thread_key, sorts.
*/
async collectCandidates(options: {
Expand Down Expand Up @@ -463,24 +309,6 @@ export class GhClient {
if (isRateLimitError(message)) poll.searchRateLimited = true;
poll.warnings.push(`review search: ${message.trim()}`);
}

try {
const tasks = await this.requiredReviewBacklog(options.limit);
poll.tasks.push(...tasks);
} catch (err) {
const message = errMessage(err);
if (isRateLimitError(message)) poll.searchRateLimited = true;
poll.warnings.push(`review backlog: ${message.trim()}`);
}

try {
const tasks = await this.assignedItems(options.limit);
poll.tasks.push(...tasks);
} catch (err) {
const message = errMessage(err);
if (isRateLimitError(message)) poll.searchRateLimited = true;
poll.warnings.push(`assignment search: ${message.trim()}`);
}
}

poll.tasks = poll.tasks.filter(
Expand Down
12 changes: 7 additions & 5 deletions src/products/breeze/engine/daemon/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,13 @@ export async function pollOnce(deps: PollOnceDeps): Promise<PollOutcome> {
try {
const stdout = gh.runChecked("fetch notifications", [
"api",
// `participating=true` restricts to notifications where the user is a
// direct participant (author, assignee, mention, review_requested) and
// respects GitHub's server-side spam filter. `?all=true` was previously
// used here but bypassed the filter, causing breeze to act on
// mention-then-delete spam surfaced to no one in the UI (#251).
// `participating=true` restricts to direct-participation notifications
// and respects GitHub's server-side spam filter. `parseNotifications`
// further narrows those results to explicit mentions and review
// requests so breeze does not act on generic author/assignee/comment
// traffic. `?all=true` was previously used here but bypassed the
// filter, causing breeze to act on mention-then-delete spam surfaced
// to no one in the UI (#251).
"/notifications?participating=true",
"--paginate",
"-H",
Expand Down
14 changes: 6 additions & 8 deletions src/products/breeze/engine/runtime/task-kind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*
* `TaskKind` classifies a GitHub notification subject + reason into the
* breeze task taxonomy. `priority_for` returns the dispatcher priority;
* `should_process_reason` gates which notification reasons get a task.
* `should_process_reason` gates which notification reasons the daemon and
* inbox treat as actionable.
*
* Pure. No I/O. Safe to import from anywhere.
*/
Expand Down Expand Up @@ -34,19 +35,16 @@ export function taskKindFromString(value: string): TaskKind | undefined {
}

/**
* Whitelist of notification `reason`s that merit dispatch. Mirrors
* `should_process_reason` in Rust — anything outside this list is
* ignored (ci_activity, subscribed, state_change, etc.).
* Whitelist of explicit GitHub signals that merit dispatch. Breeze only
* acts on direct review requests and direct mentions; other participation
* reasons (author, assign, comment, manual, etc.) stay visible on GitHub
* itself but are not treated as breeze work items.
*/
export function shouldProcessReason(reason: string): boolean {
switch (reason) {
case "review_requested":
case "comment":
case "mention":
case "team_mention":
case "assign":
case "author":
case "manual":
return true;
default:
return false;
Expand Down
Loading
Loading