Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8440a5c
feat(query): serve Prometheus exposition at /metrics?format=prometheu…
caezium Jun 15, 2026
4419e39
feat(forecast): disk-full forecaster from free-space history (A.3)
caezium Jun 15, 2026
75a3b55
feat(anomaly): baseline-vs-recent regression rules (A.2)
caezium Jun 15, 2026
d8fc3e6
feat(diff): set-membership inventory diff (B.8 + D.12)
caezium Jun 15, 2026
2d68dfa
feat(purge): git status --porcelain -b parser for purge safety (C.11)
caezium Jun 15, 2026
5088ef6
feat(doctor): diagnostics verdict composer (roadmap I)
caezium Jun 15, 2026
61c5c7d
feat(alerts): threshold engine with hysteresis + cooldown (D.12)
caezium Jun 15, 2026
2ca9f53
feat(report): weekly digest markdown composer (A.4)
caezium Jun 15, 2026
be62c41
feat(backup): tmutil output parsing for backup awareness (D.14)
caezium Jun 15, 2026
37deee0
feat(restore): restore-last-cleanup planning (D.13)
caezium Jun 15, 2026
c8a5765
feat(audit): agent-action audit record + encoding (B.5)
caezium Jun 15, 2026
a62356d
feat(ports): listening-port model + kill-safety rule (C.10)
caezium Jun 15, 2026
592b457
feat(tuneup): safe-set selection for one-click Tune-Up (#77)
caezium Jun 15, 2026
87c7306
feat(updates): brew-upgrade progress line parser (H)
caezium Jun 15, 2026
9488536
feat(hygiene): dev-ecosystem cache catalog (C.9)
caezium Jun 15, 2026
90678aa
feat(forecast): burrow_disk_forecast MCP tool over disk-free history …
caezium Jun 15, 2026
241f0d4
test(forecast): fix snapshot fixture date format so rows decode
caezium Jun 15, 2026
47b2d51
feat(diff): burrow_diff MCP tool — v1 over snapshots (B.8)
caezium Jun 15, 2026
9ac1bab
feat(report): burrow_report MCP tool — markdown digest (A.4)
caezium Jun 15, 2026
2c47736
feat(doctor): burrow_doctor MCP tool (roadmap I agent surface)
caezium Jun 15, 2026
bafeafe
feat(watcher): new-persistence-item detection (D.12)
caezium Jun 15, 2026
00d4c0d
refactor(report): shared ReportComposer.gather for GUI + MCP (A.4)
caezium Jun 15, 2026
7b49813
feat(report): Report section on Home (A.4 GUI)
caezium Jun 15, 2026
4014ff6
feat(doctor): Diagnostics section on Home (roadmap I GUI)
caezium Jun 15, 2026
362d71e
feat(hygiene): Dev hygiene section on Home — stage 1 read-only (C.9)
caezium Jun 15, 2026
aa004a1
feat(ports): native proc_listpids listening-port enumeration (C.10)
caezium Jun 15, 2026
6b07fc3
feat(ports): burrow_ports MCP tool over native enumeration (C.10)
caezium Jun 15, 2026
e7a74f1
feat(ports): Ports inspector pane as a Tool (C.10 GUI)
caezium Jun 15, 2026
f9e9551
feat(watcher): fire on new LaunchAgent/login items (D.12)
caezium Jun 15, 2026
2d37890
feat(anomaly): findings scan over per-process CPU history (A.2)
caezium Jun 15, 2026
0185f0b
feat(alerts): threshold-rule orchestrator over snapshots (D.12)
caezium Jun 15, 2026
1a054cd
feat(alerts): wire threshold alerts into the live snapshot stream (D.12)
caezium Jun 15, 2026
1033c0d
feat(audit): write agent-action audit rows on mutating tools (B.5)
caezium Jun 15, 2026
3f53c4f
fix(alerts): hop threshold post to main actor; surface anomalies in r…
caezium Jun 15, 2026
7e2a2e2
feat(hygiene): stage-2 per-item Clear-to-Trash (C.9)
caezium Jun 15, 2026
8e6ec65
feat(tuneup): Tune-Up Tool pane (#77)
caezium Jun 15, 2026
4d89e94
test(anomaly): space seeded rows past the query down-sample stride
caezium Jun 15, 2026
ce50af3
feat(restore): Restore-last-cleanup Tool pane (D.13)
caezium Jun 16, 2026
b730baf
feat(attrib+git): folder-growth diff (A.3) + git purge-safety runner …
caezium Jun 16, 2026
d0f5032
feat(backup): backup-awareness Doctor check (D.14 backup half)
caezium Jun 16, 2026
7f9c10e
feat(spike): drag-select on the History chart → top processes (A.1)
caezium Jun 16, 2026
cbc8301
feat(forecast): disk-tile 'Full in ~N' annotation (A.3)
caezium Jun 16, 2026
f205704
feat(diff): login-item churn in burrow_diff (B.8)
caezium Jun 16, 2026
d48518f
fix(forecast): DiskCard arg order (db follows minHeight in memberwise…
caezium Jun 16, 2026
d956a2c
feat(smart): SMART status Doctor check (D.14 health half)
caezium Jun 16, 2026
6263d69
feat(updates): live brew-upgrade progress (H)
caezium Jun 16, 2026
03783ae
feat(purge): git purge-safety badge on selection rows (C.11)
caezium Jun 16, 2026
e42a897
feat(sse): token-gated /events SSE stream (B.6)
caezium Jun 16, 2026
20637b2
fix(sse): rename SSE → SSEFrame (avoid AppKit-scope symbol clash)
caezium Jun 16, 2026
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
42 changes: 41 additions & 1 deletion Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,48 @@
"Close" = "关闭";
"Mole CLI not found" = "未找到 Mole CLI";

/* Home (Overview / History / Activity) + Explain */
/* Home (Overview / History / Activity / Report) + Explain */
"Overview" = "概览";
"Report" = "报告";
"Doctor" = "诊断";
"Diagnostics" = "系统诊断";
"Dev hygiene" = "开发清理";
"No developer caches found." = "未发现开发者缓存。";
"Clear" = "清理";
"Move this cache to the Trash?" = "将此缓存移到废纸篓?";
"ports" = "端口";
"Ports" = "端口";
"Listening ports" = "监听端口";
"See who's listening." = "看看谁在监听。";
"Quit this process?" = "退出此进程?";
"tuneup" = "调优";
"Tune-Up" = "调优";
"One pass, a tidier den." = "一次整理,洞穴更整洁。";
"Safe to run" = "可安全执行";
"Needs review" = "需要审查";
"Run safe set" = "执行安全项";
"Clear %@ cache" = "清理 %@ 缓存";
"Review startup item: %@" = "审查启动项:%@";
"Nothing to tune up — you're clean." = "没有需要调优的内容——很干净。";
"restore" = "恢复";
"Restore" = "恢复";
"Put back what the last clean moved." = "把上次清理移走的放回原处。";
"Restore last cleanup" = "恢复上次清理";
"Only Trash-based removals can be restored — cache deletions are permanent." = "仅可恢复移到废纸篓的项目——缓存删除是永久性的。";
"No restorable items found." = "未找到可恢复的项目。";
"Top processes in selection" = "所选区间内的主要进程";
"No process samples in that window." = "该区间内没有进程采样。";
"Full in ~%@" = "约 %@ 后写满";
"%d days" = "%d 天";
"%d weeks" = "%d 周";
"%d months" = "%d 个月";
"Uncommitted or unpushed git changes in this repo" = "此仓库有未提交或未推送的 git 更改";
"A new startup item appeared" = "出现了新的启动项";
"“%@” now launches automatically. If you didn't add it, review it." = "“%@” 现在会自动启动。如果不是你添加的,请检查它。";
"CPU usage is high" = "CPU 使用率过高";
"CPU has been pegged at %.0f%%." = "CPU 已持续满载于 %.0f%%。";
"Memory pressure is high" = "内存压力过高";
"Memory is at %.0f%%." = "内存使用率为 %.0f%%。";
"Explain" = "解读";

/* Language switch */
Expand Down
42 changes: 41 additions & 1 deletion Resources/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,48 @@
"Close" = "關閉";
"Mole CLI not found" = "找不到 Mole CLI";

/* Home (Overview / History / Activity) + Explain */
/* Home (Overview / History / Activity / Report) + Explain */
"Overview" = "總覽";
"Report" = "報告";
"Doctor" = "診斷";
"Diagnostics" = "系統診斷";
"Dev hygiene" = "開發清理";
"No developer caches found." = "未發現開發者快取。";
"Clear" = "清理";
"Move this cache to the Trash?" = "將此快取移到垃圾桶?";
"ports" = "連接埠";
"Ports" = "連接埠";
"Listening ports" = "監聽連接埠";
"See who's listening." = "看看誰在監聽。";
"Quit this process?" = "退出此程序?";
"tuneup" = "調校";
"Tune-Up" = "調校";
"One pass, a tidier den." = "一次整理,洞穴更整潔。";
"Safe to run" = "可安全執行";
"Needs review" = "需要審查";
"Run safe set" = "執行安全項";
"Clear %@ cache" = "清理 %@ 快取";
"Review startup item: %@" = "審查啟動項:%@";
"Nothing to tune up — you're clean." = "沒有需要調校的內容——很乾淨。";
"restore" = "復原";
"Restore" = "復原";
"Put back what the last clean moved." = "把上次清理移走的放回原處。";
"Restore last cleanup" = "復原上次清理";
"Only Trash-based removals can be restored — cache deletions are permanent." = "僅可復原移到垃圾桶的項目——快取刪除是永久性的。";
"No restorable items found." = "未找到可復原的項目。";
"Top processes in selection" = "所選區間內的主要程序";
"No process samples in that window." = "該區間內沒有程序取樣。";
"Full in ~%@" = "約 %@ 後寫滿";
"%d days" = "%d 天";
"%d weeks" = "%d 週";
"%d months" = "%d 個月";
"Uncommitted or unpushed git changes in this repo" = "此儲存庫有未提交或未推送的 git 變更";
"A new startup item appeared" = "出現了新的啟動項";
"“%@” now launches automatically. If you didn't add it, review it." = "“%@” 現在會自動啟動。如果不是你新增的,請檢查它。";
"CPU usage is high" = "CPU 使用率過高";
"CPU has been pegged at %.0f%%." = "CPU 已持續滿載於 %.0f%%。";
"Memory pressure is high" = "記憶體壓力過高";
"Memory is at %.0f%%." = "記憶體使用率為 %.0f%%。";
"Explain" = "解讀";

/* Language switch */
Expand Down
43 changes: 43 additions & 0 deletions Sources/AgentAudit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// AgentAudit.swift
// Burrow
//
// Agent-action audit records (roadmap B.5): the trust feature that makes
// "let agents act" adoptable — every MCP tool dispatch leaves a row humans
// can read. This is the record shape + its stable JSON encoding (one DB row
// under `prefix`). Writing rows from the MCP process through a serialized
// writer, and the Activity-view pane, are integration.
//
// Args are NOT redacted — they're local, and the whole point is to show
// exactly what an agent asked for.
//

import Foundation

enum AgentAudit {
static let prefix = "burrow.agent_audit"

struct Entry: Codable, Equatable {
var tool: String
var client: String
var dryRun: Bool
var durationMs: Int
var ok: Bool
var summary: String
/// The tool's arguments, pre-serialized to a JSON string by the caller.
var argsJSON: String
}

/// Canonical, key-sorted JSON for one row — deterministic so tests and
/// the GUI read the same bytes the MCP process wrote.
static func encode(_ e: Entry) -> String {
let enc = JSONEncoder()
enc.outputFormatting = [.sortedKeys]
guard let d = try? enc.encode(e), let s = String(data: d, encoding: .utf8) else { return "{}" }
return s
}

static func decode(_ json: String) -> Entry? {
try? JSONDecoder().decode(Entry.self, from: Data(json.utf8))
}
}
53 changes: 53 additions & 0 deletions Sources/AlertEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// AlertEngine.swift
// Burrow
//
// Threshold-alert evaluation (roadmap D.12). Pure and stateful-by-value: a
// rule + the latest reading + the prior state → the next state and whether
// to fire. Hysteresis (fire on crossing `high`, don't re-arm until the value
// recovers below `low`) plus a cooldown mean an alert fires once per
// *episode*, not once per sample — the difference between a useful nudge and
// alert fatigue. The same evaluator backs notifications (D.12), the SSE
// stream (B.6), and the report (A.4); the rules' thresholds, the Sampler
// wiring, and UserNotifications delivery are integration.
//

import Foundation

struct ThresholdRule: Equatable {
let id: String
/// Fire when the reading reaches `high`…
let high: Double
/// …and don't re-arm until it falls back below `low` (hysteresis).
let low: Double
/// Minimum seconds between fires, across episodes.
let cooldownSeconds: Int
}

struct AlertState: Equatable {
/// In an active episode: above `high` and not yet recovered below `low`.
var firing = false
var lastFiredTS: Int?
}

enum AlertEngine {
/// Fold one reading into the alert state. Returns the next state and
/// whether this tick should emit an alert.
static func step(rule: ThresholdRule, value: Double, ts: Int,
state: AlertState) -> (state: AlertState, fired: Bool) {
var s = state
if s.firing {
// Episode continues until the value recovers below `low`; dips
// that stay above `low` do not re-fire.
if value < rule.low { s.firing = false }
return (s, false)
}
guard value >= rule.high else { return (s, false) }
s.firing = true
if let last = s.lastFiredTS, ts - last < rule.cooldownSeconds {
return (s, false) // armed, but still cooling down from the last fire
}
s.lastFiredTS = ts
return (s, true)
}
}
77 changes: 77 additions & 0 deletions Sources/Anomaly.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// Anomaly.swift
// Burrow
//
// Baseline-vs-recent regression detection (roadmap A.2). Pure statistics:
// arrays in, a verdict out — so the "WindowServer baseline doubled" and
// "battery drains faster than two weeks ago" findings are computed the same
// way wherever they're surfaced (Home card, notifications, the Explain lens,
// a future MCP tool), and the credibility tuning lives in one place.
//
// The rules are deliberately conservative: a finding needs a clear effect
// size on top of statistical exceedance, because a false "your Mac is
// degrading" alert costs more trust than a missed one.
//

import Foundation

enum Anomaly {
// MARK: Statistical primitives

/// Linear-interpolated percentile (the numpy/"type 7" convention), so
/// p95 of a short baseline is stable and well-defined. `p` is 0...100.
static func percentile(_ xs: [Double], _ p: Double) -> Double {
guard !xs.isEmpty else { return 0 }
let s = xs.sorted()
if s.count == 1 { return s[0] }
let rank = (p / 100.0) * Double(s.count - 1)
let lo = Int(rank.rounded(.down))
let hi = Int(rank.rounded(.up))
let frac = rank - Double(lo)
return s[lo] + (s[hi] - s[lo]) * frac
}

static func median(_ xs: [Double]) -> Double { percentile(xs, 50) }

// MARK: Process CPU regression

/// True when a process's recent CPU is *sustainedly* above its own
/// baseline: the typical recent value clears the baseline's 95th
/// percentile AND beats the baseline median by at least `minDelta` points.
/// The effect-size floor stops near-idle processes (0.5% → 2%) from
/// tripping a "doubled!" alert that's technically true but meaningless.
static func processCPUExceedsBaseline(baseline: [Double], recent: [Double],
minSamples: Int = 5, minDelta: Double = 10) -> Bool {
guard baseline.count >= minSamples, recent.count >= minSamples else { return false }
let recentMid = median(recent)
return recentMid > percentile(baseline, 95)
&& (recentMid - median(baseline)) >= minDelta
}

// MARK: Battery drain regression

/// Discharge rate of one session in percent-per-hour (positive = losing
/// charge), via least-squares slope over elapsed hours. Robust to a
/// noisy reading or two. nil when the session is too short to have a rate.
static func batteryDrainRate(_ session: [(ts: Int, percent: Double)]) -> Double? {
guard session.count >= 2 else { return nil }
let pts = session.map { (h: Double($0.ts) / 3600.0, p: $0.percent) }
let n = Double(pts.count)
let meanH = pts.reduce(0) { $0 + $1.h } / n
let meanP = pts.reduce(0) { $0 + $1.p } / n
var num = 0.0, den = 0.0
for pt in pts { num += (pt.h - meanH) * (pt.p - meanP); den += (pt.h - meanH) * (pt.h - meanH) }
guard den > 0 else { return nil }
return -(num / den) // negate: a falling % is a positive drain rate
}

/// True when the recent discharge drains meaningfully faster than the
/// baseline sessions: at least `factor`× the median baseline rate and at
/// least `minDelta` %/hr faster in absolute terms.
static func batteryDrainRegressed(baselineRates: [Double], recentRate: Double,
factor: Double = 1.25, minDelta: Double = 1) -> Bool {
guard !baselineRates.isEmpty else { return false }
let base = median(baselineRates)
return base > 0 && recentRate >= base * factor && (recentRate - base) >= minDelta
}
}
46 changes: 46 additions & 0 deletions Sources/AnomalyScan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// AnomalyScan.swift
// Burrow
//
// Connects the Anomaly rules (A.2) to real history: per-process CPU samples
// for a recent window vs a baseline window → the processes whose usage has
// regressed. Pure over the two sample maps (which MetricsStore.processCPUSamples
// produces), so it's testable without a DB. Persisting findings under
// `burrow.findings` (Maintenance pass) and the Home "Changes" card are
// integration.
//

import Foundation

enum AnomalyScan {
struct Finding: Equatable {
let process: String
let recentMedian: Double
let baselineMedian: Double
}

/// Convenience: pull recent (last 24h) vs baseline (prior 14d) per-process
/// CPU from the store and flag regressions. The window split the roadmap
/// specifies for A.2.
static func scan(metrics: MetricsStore, now: Int) -> [Finding] {
let day = 86_400
let recent = metrics.processCPUSamples(.init(since: now - day, until: now))
let baseline = metrics.processCPUSamples(.init(since: now - 15 * day, until: now - day))
return cpuFindings(baseline: baseline, recent: recent)
}

/// Flag every process whose recent CPU clears its own baseline per the
/// Anomaly rule, worst (highest recent median) first.
static func cpuFindings(baseline: [String: [Double]],
recent: [String: [Double]]) -> [Finding] {
var out: [Finding] = []
for (name, recentSamples) in recent {
guard let baseSamples = baseline[name] else { continue }
guard Anomaly.processCPUExceedsBaseline(baseline: baseSamples, recent: recentSamples) else { continue }
out.append(Finding(process: name,
recentMedian: Anomaly.median(recentSamples),
baselineMedian: Anomaly.median(baseSamples)))
}
return out.sorted { $0.recentMedian > $1.recentMedian }
}
}
31 changes: 31 additions & 0 deletions Sources/BackupStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// BackupStatus.swift
// Burrow
//
// Backup awareness (roadmap D.14, backup half): how stale is Time Machine?
// Runs `tmutil latestbackup` (fast, unprivileged) and reduces it to a
// days-ago count via the tested TimeMachine parser. Used by the Doctor check
// (GUI + burrow_doctor). The SMART/IOKit half of D.14 is separate native work.
//

import Foundation

enum BackupStatus {
/// Whole days since the most recent Time Machine backup, or nil when there
/// are no backups / Time Machine is unavailable.
static func lastBackupDaysAgo(now: Date = Date()) -> Int? {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil")
p.arguments = ["latestbackup"]
let out = Pipe()
p.standardOutput = out
p.standardError = Pipe()
do { try p.run() } catch { return nil }
p.waitUntilExit()
guard p.terminationStatus == 0 else { return nil }
let text = String(decoding: out.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
guard let token = TimeMachine.latestBackupToken(text),
let date = TimeMachine.date(fromToken: token) else { return nil }
return max(0, Int(now.timeIntervalSince(date) / 86_400))
}
}
23 changes: 23 additions & 0 deletions Sources/BrewProgress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// BrewProgress.swift
// Burrow
//
// Brew-upgrade progress parsing (roadmap H deferral). Pure: a line of
// `brew upgrade` output → a human progress phrase, or nil for noise — so the
// Updates flow can show live progress ("Pouring foo…") instead of a blocking
// spinner. Wiring this into the streaming upgrade run is integration.
//

import Foundation

enum BrewProgress {
/// Brew prints its step headers as `==> <phrase>`. Those are exactly the
/// progress beats worth surfacing; everything else (download bars, blank
/// lines, bottle hashes) is noise.
static func phrase(_ line: String) -> String? {
let t = line.trimmingCharacters(in: .whitespaces)
guard t.hasPrefix("==> ") else { return nil }
let phrase = String(t.dropFirst(4)).trimmingCharacters(in: .whitespaces)
return phrase.isEmpty ? nil : phrase
}
}
Loading
Loading