Reference#
CLI#
Generated at build time from the click commands.
perch#
perch – what the open board means for the budget.
Usage
perch [OPTIONS] COMMAND [ARGS]...
accuracy#
How estimates compared with what the work took.
Usage
perch accuracy [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
alerts#
Every alert rule from config.yaml and whether it is true now. Read only.
Usage
perch alerts [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project (default).
bg#
Budgie’s monthly, hours, plan, scenario, assumptions, calibrate, doctor.
Run inside the project’s budget dir; SUB –help is Budgie’s.
Usage
perch bg [OPTIONS] {monthly|hours|plan|scenario|assumptions|calibrate|doctor}
[ARGS]...
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
Arguments
- SUB#
Required argument
- ARGS#
Optional argument(s)
board#
Step 2: cost to clear the open board, against the hours and budget left.
Usage
perch board [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --seed <seed>#
Seed the simulation.
- --iterations <iterations>#
Simulation draws.
- --no-history#
Don’t append to history.jsonl.
brief#
Everything /walk reads for one project, as plain text. Reads only.
Usage
perch brief [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
budget#
Which input files Budgie reads for the project, and what each feeds.
Usage
perch budget [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
cut#
What no longer fits after a budget cut or plan change. Writes nothing.
With flags, today’s files against today’s files changed by the flags. With none, the last week perch board recorded against today’s files.
Usage
perch cut [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --budget <new_budget>#
What-if: this budget instead of the latest.
- --leaves <NAME:YYYY-MM-DD>#
What-if: NAME is at 0 FTE from that date. Repeatable.
- --fte <NAME:YYYY-MM-DD:FTE>#
What-if: NAME is at FTE from that date. Repeatable.
detail#
One project’s burn, plan and forecast by month. Team level, labor only.
Usage
perch detail [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
digest#
Step 4: gitboard’s team and per-person digest, from the same dump.
Usage
perch digest [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
doctor#
Check the tools, each project’s config, and how fresh the data is.
Usage
perch doctor [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
emails#
Step 5: Budgie’s per-person hours-left drafts (.eml); nothing is sent.
Usage
perch emails [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
events#
Dated things ahead: budget and staffing moves, milestones, quarter ends, holidays.
Usage
perch events [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project (default).
- --days <days>#
How far ahead to look.
- Default:
30
fetch#
Step 1: one GitLab read, gitboard stats into the project’s board dump.
Usage
perch fetch [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
forecast#
Budgie’s cost forecast for the project, with P10/P50/P90.
Usage
perch forecast [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
gb#
gitboard for the project, from its checkout; SUB –help is gitboard’s.
push and pull need GitLab.
Usage
perch gb [OPTIONS]
{show|report|stats|graph|estimate|plan|status|push|pull|sync|members}
[ARGS]...
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
Arguments
- SUB#
Required argument
- ARGS#
Optional argument(s)
hours#
Step 0: open the project’s weekly.csv to paste this week’s timesheet totals.
Usage
perch hours [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
init#
Scaffold projects/NAME/perch.yaml and its Budgie project projects/NAME/budget.
–year and –year-start go to budgie init, so a fiscal-year project needs no hand edit of budgie.yaml.
Usage
perch init [OPTIONS] NAME
Options
- --year <year>#
Budgie’s budget year, e.g. 2027 (new project only).
- --year-start <year_start>#
First month of the year as MM-01; federal fiscal is 10-01 (Budgie checks it).
- --gitlab <gitlab>#
The GitLab path, e.g. group/NAME (fills gitlab_project:).
Arguments
- NAME#
Required argument
listen#
Claude stages board edits from the lead’s lines in a WebVTT transcript (/listen).
Usage
perch listen [OPTIONS] MEETING.vtt
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
Arguments
- MEETING.vtt#
Required argument
monday#
Steps 1-5: fetch, board, weekly, digest, emails. Run perch hours first.
Nothing is ever sent: you review the drafts and send them yourself. Last, the private watch goes to projects/NAME/watch/, never beside the drafts.
Usage
perch monday [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project, in name order; a failure moves on to the next.
- --from <start>#
Resume at this step instead of fetch.
- Options:
fetch | board | weekly | digest | emails
page#
One HTML file: board, accuracy, the weeks recorded and the budget. Read only.
Usage
perch page [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --out <out_path>#
Write here, not page.html.
projects#
The projects in this workspace and how fresh each one’s data is.
Usage
perch projects [OPTIONS]
quarterly#
The quarter’s money for the funder: an Outlook draft (.eml) and a .md.
Nothing is sent: open the draft in Outlook, review it, send it yourself.
Usage
perch quarterly [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project, in name order; a failure moves on to the next.
- --quarter <quarter>#
e.g. 2026-Q3, or FY27-Q1 for a fiscal year; default the last complete one.
- --out <out_dir>#
Write here instead of projects/<name>/quarterly/ (–all: one folder each).
review#
Claude’s /board on the pulled board: labels, priority, flags, with the budget.
Usage
perch review [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
status#
Each project’s Monday steps: ok, stale, todo or FAIL, and the next command.
Usage
perch status [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
suite#
perch, Budgie and gitboard side by side; P, B and G hop between them at once.
Runs them in a hidden tmux of their own. Closing the terminal leaves the suite running: run this again to come back to it. q in perch closes it.
Usage
perch suite [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
tape#
What changed lately, newest first, from files only.
Usage
perch tape [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project (default).
- --days <days>#
How far back to look.
- Default:
56
tui#
Every project in one table; single keys run perch on the selected one.
With tmux installed this starts perch suite (or comes back to it), so B and G hop to Budgie and gitboard at once. Inside the suite, without tmux, or with –no-suite it is just this TUI.
Usage
perch tui [OPTIONS]
Options
- --no-suite#
Just the perch TUI, without starting perch suite.
walk#
Claude walks you through the project’s money, board and watch (/walk).
Usage
perch walk [OPTIONS]
Options
- -p, --project <project>#
Which project under projects/ (needed when there are several).
watch#
Private: anyone out of line with their own last 8 weeks. For you only.
Usage
perch watch [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --all#
Every project, in name order; a failure moves on to the next.
weekly#
Step 3: per-person markdown drafts for the weekly digest. Nothing is sent.
Usage
perch weekly [OPTIONS]
Options
- --config <config_path>#
A perch.yaml to use instead of a workspace project.
- -p, --project <project>#
Which project under projects/ (needed when there are several).
- --person <person>#
Only this person (a name in people:).
- --out <out_path>#
Write the markdown here.
Modules#
Everything under perch.core is UI-free; perch.cli is a thin adapter over it.
perch.yaml: where the two tools’ outputs live, and who is who.
- class perch.core.config.Config(root: Path, budgie_project: Path, board_dump: Path, estimates: Path | None = None, people: dict[str, str]=<factory>, gitlab_project: str | None = None, unmatched: tuple[str, ...]=())#
A loaded perch.yaml. Every path is absolute.
- perch.core.config.load_config(path: str | Path, require_dump: bool = True) Config#
Load and validate a perch.yaml. Paths resolve against its directory.
require_dump=Falseis for the steps that run before the first fetch (doctor, fetch itself): the dump’s path is still required, its file is not.
The perch workspace: the perch checkout is home, and the projects live in it:
perch/ the checkout
config.yaml optional, gitignored: lead: NAME and alerts:
apps/budgie/ clones (gitignored), put there by make install
apps/remote-gitboard/
projects/apollo/ perch.yaml, history.jsonl, watch/, monday.json
board/ the pulled board, its .base, snapshots, stats, dump
budget/ the Budgie project (budgie.yaml directly inside)
reports/ digests from gitboard digest
listen/ meeting transcripts
A project is one funded piece of work: one GitLab project, one Budgie project, one timesheet charge code. Home is where perch is installed from: no file to find, no environment variable.
- class perch.core.workspace.Home(root: Path, lead: str | None = None, alerts_raw: object = None)#
The perch checkout and what config.yaml says.
rootis absolute.- alerts()#
The validated alert rules; ValueError naming the key when malformed.
- env_path(name: str) Path#
This project’s tokens; the checkout’s .env is the shared fallback.
- projects() list[str]#
Project names: folders under projects/ that hold a perch.yaml.
- scaffold(name: str, gitlab: str | None = None) Path#
Write projects/NAME/perch.yaml pointing at its budget/, and a .env template. Never overwrites either.
- select(name: str | None, here: Path | None = None) str#
The named project, else the one
hereis inside, else the only one.Several projects, none named and
herein none of them is an error.
- property venv_bin: Path#
Where make install writes the perch, budgie and gitboard commands.
- watch_path(name: str, week: str) Path#
The private watch: under the project, never beside the drafts.
- exception perch.core.workspace.WorkspaceError#
No workspace, no such project, or several projects and none named.
- perch.core.workspace.check_name(name: str) str#
A project name is one plain path segment, so it can’t leave projects/.
- perch.core.workspace.checkout_home(root: Path | None = None) Home#
The perch checkout as a Home;
rootis injected by tests only.
- perch.core.workspace.name_too_long(name: str) bool#
Legal, but long enough that every command line and table suffers.
What each perch command runs, as values. Nothing here executes anything.
The CLI turns a Step into a subprocess. Keeping the commands as data is what lets the tests check monday – order, working directory, paths – without GitLab, Budgie or a terminal. perch runs the other tools as commands, never as imports, so it still never calls GitLab and never redoes budget math.
- class perch.core.steps.Step(name: 'str', argv: 'tuple[str, ...]', cwd: 'Path', env: 'Mapping[str, str]'=<factory>, makes: 'tuple[Path, ...]'=())#
- perch.core.steps.bg(bin_dir: Path, config: Config, sub: str, args: tuple[str, ...] = ()) Step#
Budgie SUB for one project: it runs inside it, so –project is perch’s.
- perch.core.steps.budgie_init(bin_dir: Path, home: Home, name: str, year: str | None = None, year_start: str | None = None) Step#
budgie init –here inside projects/NAME/budget writes the Budgie project.
year / year_start pass straight through; Budgie validates them.
- perch.core.steps.gb(home: Home, project: str, config: Config, sub: str, args: tuple[str, ...] = ()) Step#
gitboard for one project, run from its checkout with the target filled in.
The offline seven read the pulled spec, its .base, the board dump (the same data the money came from) and the snapshot log. status takes no target: it is every pulled board in the checkout, not only this project’s. migrate is not here on purpose.
- perch.core.steps.gitboard(home: Home, name: str, *args: str, makes: tuple[Path, ...] = (), project: str | None = None) Step#
gitboard runs from the project’s folder, where it finds the project’s .env.
gitboard walks up from its cwd for .env (and gitboard.toml), so projects/NAME/.env wins and the checkout’s .env is the shared fallback. With no project (doctor’s –help) it runs from home, never from gitboard’s own checkout, whose development gitboard.toml must not reach real runs.
- perch.core.steps.hours(editor: str, config: Config) Step#
$EDITOR may carry flags (code -w), so it is split like a shell would.
- perch.core.steps.iso_week(day: date) str#
2026-W40, the same as date +%G-W%V.
- perch.core.steps.listen(home: Home, project: str) Step#
Claude on /listen in the checkout, where .claude/commands/listen.md lives.
- perch.core.steps.monday(bin_dir: Path, home: Home, project: str, config: Config, week: str, start: str | None = None) list[Step]#
Steps 1-5, in order, from start on. hours (step 0) is by hand.
- perch.core.steps.people_lines(rows: list[dict], people: Mapping[str, str]) list[str]#
The latest recorded week’s person rows, in name order: load, not pay.
Hours only, as perch board shows them: no rate, cost or accuracy.
- perch.core.steps.pull_fix(project: str, spec: Path) str#
What to run when the board has no .base, without losing staged edits.
gitboard guards pull –force against unpushed edits only through the .base, so with none it would overwrite them silently.
- perch.core.steps.review(home: Home, project: str, config: Config, rows: list[dict]) Step#
Claude’s /board on the pulled board, told what perch knows about the money.
Runs in the perch checkout, where walk.install(home, “board”) put /board.
rows is history.jsonl: the team lines and the latest week’s person lines, never the watch. /board stages edits in the project’s board file, so no pull, no review.
- perch.core.steps.run_projects(names: Iterable[str], run_one: Callable[[str], None]) list[tuple[str, Exception | None]]#
Run each project in turn; a failure is recorded and the next one still runs.
- perch.core.steps.spec_path(home: Home, project: str, config: Config) Path#
The project’s pulled board: projects/NAME/board/NAME.yaml (NAME is the folder).
- perch.core.steps.walk(home: Home, project: str) Step#
Claude on /walk in the workspace, where .claude/commands/walk.md lives.
perch listen: the lead’s lines out of a WebVTT transcript.
- perch.core.listen.parse(vtt_text: str, lead: str) list[str]#
The lead’s cues, in order. A cue’s speaker is its first line’s <v Name> tag, else a Name: prefix; the whole name must match (case aside).
- perch.core.listen.write(home: Home, project: str, lines: list[str], today: date) Path#
projects/P/listen/YYYY-MM-DD.txt, then -2, -3: never overwrites.
perch doctor: one ok-or-FIX line for each thing that can be wrong before Monday.
- class perch.core.doctor.Check(ok: 'bool', what: 'str', fix: 'str' = '')#
- perch.core.doctor.alert_checks(home: Home) list[Check]#
One check for the alert rules: none set is fine, a bad one names its key.
- perch.core.doctor.env_checks(home: Home, name: str, environ: Mapping[str, str]) list[Check]#
Where this project’s read token comes from; never what it is.
gitboard’s order: an exported variable, then the nearest .env walking up from projects/NAME (the project’s, then the checkout’s).
- perch.core.doctor.freshness(home: Home, name: str) list[tuple[str, str]]#
When the dump, weekly.csv and history were last written (“never” if not).
- perch.core.doctor.old_layout_checks(start: Path, home: Home) list[Check]#
A perch-home.yaml at or above
startis the old workspace: print its moves.Detection only, the one place a walk-up remains. Nothing is moved.
- perch.core.doctor.old_venv_checks(user_home: Path) list[Check]#
The venvs make install used before perch/.venv: named, never deleted.
- perch.core.doctor.project_checks(home: Home, name: str) list[Check]#
perch.yaml loads, gitlab_project is set, and people: names exist in Budgie.
- perch.core.doctor.read_env(path: Path) dict[str, str]#
KEY=value lines the way python-dotenv reads them; {} for no file.
Enough of dotenv for doctor to see whether a key is set: comments, export `, quotes and a trailing ` # note. Values are never printed.
Where Monday stands: one cell per step, read from the files each step writes.
Nothing here runs a step, fits a rate or reads the watch; it only looks at files and dates, so a TUI can call it on every redraw. A step that failed in perch monday leaves projects/NAME/monday.json; the cell shows failed until that step’s output is newer than the record.
- class perch.core.status.Cell(state: 'str', when: 'str', why: 'str' = '')#
- perch.core.status.clear_failure(home: Home, name: str, ran: tuple[str, ...] = ('hours', 'fetch', 'board', 'weekly', 'digest', 'emails', 'watch')) None#
Drop the record once a run got through its step (ran: the steps run).
- perch.core.status.next_command(name: str, step: str) list[str]#
The perch argv (after perch) that does step for name.
- perch.core.status.status(home: Home, name: str, today: date) dict[str, Cell]#
Every step’s cell; a project that will not load is error throughout.
The gitboard side: a gitboard stats –dump file as plain dataclasses.
Read-only, and the only thing perch knows about GitLab. The dump’s shape is gitboard’s board.fetch_history; perch reads the few fields it needs and ignores the rest, so gitboard can add fields without breaking this.
- class perch.core.board.Board(project: 'str', name: 'str', fetched_on: 'date', issues: 'tuple[Issue, ...]', columns: 'tuple[str, ...]' = (), since: 'date | None' = None)#
- class perch.core.board.Issue(iid: 'int', title: 'str', assignee: 'str | None', labels: 'tuple[str, ...]', closed_on: 'date | None', blocked_since: 'date | None' = None, questions: 'tuple[str, ...]' = (), milestone: 'str | None' = None, milestone_due: 'date | None' = None, last_moved: 'date | None' = None, transitions: 'tuple[tuple[date, str, str], ...]' = (), created_on: 'date | None' = None)#
- property type: str#
The first type::x label’s value, the way gitboard reads it.
estimates.csv: business estimates, massaged into one flat shape.
key,hours,low,high #14,24,, epic::billing,400,320,520
A key starting with # is one issue by iid; any other key is a label and covers every issue carrying it.
- class perch.core.estimates.Estimate(key: 'str', low: 'float', hours: 'float', high: 'float')#
The Budgie side: everything perch reads out of a Budgie project.
This module, estimates (csvio), join (rollup, YearSpan), rate and cut (YearSpan; cut also PlanEntry), quarterly (the forecast, YearSpan) and watch (working days) are the only places that import budgie.core, so a Budgie refactor has a short list of things it can break here.
- class perch.core.money.Money(span: 'YearSpan', hourly_cost: 'dict[str, float]', readings: 'dict[str, list[Reading]]' = <factory>, allocated: 'dict[str, float]' = <factory>, spent: 'dict[str, float]' = <factory>, non_labor: 'float' = 0.0, costs: 'list[CostItem]' = <factory>, budget: 'float | None' = None, iterations: 'int' = 10000, seed: 'int | None' = None, pace: 'dict[str, BurndownStatus]' = <factory>, people: 'list[Person]' = <factory>, pto: 'float' = 0.0, budget_revisions: 'Budget | None' = None, plan: 'AllocationPlan | None' = None, planned_on: 'Callable[[str, date], float | None] | None' = None, burn: 'Callable[[], BurnSeries] | None' = None)#
- property as_of: date | None#
The latest reading anyone has – the moment the money side describes.
- booked(name: str, start: date, end: date) float | None#
Hours booked after
startthroughend, interpolated between readings the way Budgie’s monthly view does. None without readings.
- property left: dict[str, float]#
Planned hours each person has not yet spent.
- planned(name: str, start: date, end: date) float | None#
Hours Budgie’s pace line plans for
nameafterstart, throughend.
- team_planned(start: date, end: date) float | None#
Planned team hours after
startthroughend, read the way booked reads hours: sampled on each person’s reading days (the team’s, for someone without readings) and interpolated between them by Budgie’s spent_at. The plan counts working days and readings interpolate by calendar day, so only the same sampling makes the two comparable.Budgie’s planned people are allocated (allocations.csv, else the plan alone) and their hours its planned_through. Someone who books without being planned adds to booked hours only. None when nobody is planned or nothing has been read.
- perch.core.money.load_money(project: str | Path) Money#
The project as Budgie reads it: every input-precedence rule is Budgie’s.
- perch.core.money.money_from(snap: Snapshot) Money#
Money from an already-loaded (or what-if) Budgie snapshot.
- perch.core.money.what_if(snap: Snapshot, budget: float | None = None, changes: Sequence[PlanEntry] = ()) Snapshot#
Budgie’s what-if: a new budget and/or plan changes. Budgie carries a changed person who is only in allocations.csv at their flat FTE from the year’s first day, so Bob leaves in July keeps January to June as it was.
Calibrated hours-per-issue rates from weekly readings and closed issues.
- class perch.core.rate.Interval(name: str, start: date, end: date, hours: float, closed: dict[str, int]=<factory>)#
One person’s hours between two readings, and what they closed in it.
- class perch.core.rate.Rate(low: float, mode: float, high: float, samples: int, basis: str)#
Hours per issue, as a three-point figure.
- class perch.core.rate.TypeModel(rates: dict[str, float], factors: dict[str, float], columns: dict[str, str], fallback: str, intervals: int)#
hours = factor(person) x sum(rate(column) x issues in that column).
A column is an issue type with enough history to carry its own rate, or
other.columnsmaps every issue type seen to the column it was counted in; a type never seen before usesfallback.
- perch.core.rate.build_intervals(readings: dict[str, list[tuple[date, float]]], closed: list[tuple[str, date, str]], span: YearSpan, since: date | None = None) list[Interval]#
Cut each person’s year into reading-to-reading intervals.
The first interval opens where
windowsays. An interval in which the person closed nothing is merged into the next one (its hours were still spent getting the next issues done); a trailing one is merged backwards.
- perch.core.rate.fit_types(intervals: list[Interval], min_issues: int = 5) TypeModel | None#
Fit team type rates and one pace factor per person.
Types with fewer than
min_issuesclosed issues share anothercolumn; if even that is undermin_issuesthey are counted with the commonest type instead, because a column resting on a couple of issues fits noise. A column whose rate comes out negative is merged away and the fit is run again. Returns None when there are fewer intervals thancolumns + 2– too little data to fit honestly.
- perch.core.rate.person_rates(intervals: list[Interval]) dict[str, Rate]#
Each person’s own rate; people who closed nothing are left out.
- perch.core.rate.team_rate(intervals: list[Interval]) Rate | None#
Everyone’s hours over everyone’s closed issues.
- perch.core.rate.window(series: list[tuple[date, float]], span: YearSpan, since: date | None) tuple[date, float]#
Where a person’s measurable work opens: (first day, hours booked before it).
The year’s first day with nothing booked, unless the board dump’s history starts later. Then it is the day after the first reading that leaves no gap before
since: hours booked before that were spent on issues the dump cannot see, so they would inflate hours per issue.sinceNone (dumps from before gitboard wrote it) means no clip.
The join: open issues -> hours -> dollars, against the hours and budget left.
- class perch.core.join.IssueHours(issue: 'Issue', low: 'float', mode: 'float', high: 'float', basis: 'str')#
- class perch.core.join.PersonRow(name: 'str', open: 'int', low: 'float', mode: 'float', high: 'float', hourly_cost: 'float | None', left: 'float | None', bases: 'tuple[str, ...]', no_basis: 'int')#
- property gap: float | None#
it does not fit.
- Type:
Planned hours left after the open board. Negative
- class perch.core.join.Rates(people: 'dict[str, Rate]', team: 'Rate | None', types: 'TypeModel | None')#
- class perch.core.join.Rollup(as_of: 'date | None', spent_cost: 'float', clear_p10: 'float', clear_p50: 'float', clear_p90: 'float', non_labor: 'float', budget: 'float | None', signal: 'SignalResult | None')#
- perch.core.join.calibrate(readings: dict[str, list[tuple[date, float]]], board: Board, people: dict[str, str], span: YearSpan) Rates#
Rates from the year’s readings and the issues each mapped person closed.
Only from where the dump’s history starts: earlier closes are not in it.
- perch.core.join.hours_for(issue: Issue, name: str | None, estimates: dict[str, Estimate], rates: Rates) IssueHours | None#
Hours for one issue: its own estimate, else the type model, else a rate.
Returns None when there is no basis at all. perch does not invent a default hours-per-issue.
- perch.core.join.notes(board: Board, rows: list[PersonRow], people: dict[str, str], money: Money)#
Everything the lead should know about how far to trust this run.
- perch.core.join.person_rows(board: Board, estimates: dict[str, Estimate], rates: Rates, people: dict[str, str], money: Money) list[PersonRow]#
One row per mapped person with open issues, then unassigned and unmapped.
- perch.core.join.rollup(rows: list[PersonRow], money: Money, iterations: int | None = None, seed: int | None = None) Rollup#
Cost to clear the board, simulated and signalled by Budgie’s own engine.
How estimates compared with what the work took.
Two tables, and the difference between them matters. By label is modelled: nobody books hours per issue, so an epic’s hours are each closer’s rate summed. By person is measured: the hours a person booked against the estimates on the issues they closed.
- class perch.core.accuracy.LabelAccuracy(label: 'str', issues: 'int', open: 'int', estimated: 'float', modelled: 'float', dollars: 'float')#
- class perch.core.accuracy.PersonAccuracy(name: 'str', closed: 'int', covered: 'int', booked: 'float', estimated: 'float')#
- property ratio: float | None#
Booked over estimated, on the covered share of the work.
None below 50% coverage: a ratio over a minority of someone’s work says nothing about them.
history.jsonl: one run per week, kept so that trends have somewhere to start.
perch board writes it; perch weekly reads it. Quarterly will too.
- perch.core.history.figures(rows: list[dict], kind: str, name: str, key: str) dict[str, float]#
One recorded figure by week (e.g. Alice’s accuracy ratio); weeks without it are left out.
- perch.core.history.latest_week(path: str | Path) list[dict]#
The rows of the last week recorded; empty when nothing is.
- perch.core.history.load(path: str | Path) list[dict]#
Every recorded row; a missing file is an empty history.
- perch.core.history.record(path: str | Path, day: date, rows: list[dict]) None#
Write this week’s rows, replacing any the same week already has.
- perch.core.history.rows_for(people: list[PersonRow], rates: Rates, rollup: Rollup, accuracy: list[PersonAccuracy] = (), left: float | None = None, quarter: dict | None = None) list[dict]#
This run’s rows; left is the team’s planned hours left (for perch cut), quarter the quarter so far’s pace and net_scope (quarterly.quarter_to_date).
perch cut: the join run backwards – what a budget cut or plan change does.
perch shows the gap and each milestone’s open work; it never orders issues or suggests what to drop. The allocation math is Budgie’s (money.what_if).
- class perch.core.cut.Cut(before: 'Side', after: 'Side', board_hours: 'float', people: 'list[PersonCut]', milestones: 'list[Milestone]')#
- property spare: float | None#
short.
- Type:
Hours left after the change less the board. Negative
- class perch.core.cut.Milestone(name: 'str | None', due: 'date | None', open: 'int', low: 'float', mode: 'float', high: 'float', no_basis: 'int' = 0)#
- class perch.core.cut.PersonCut(name: 'str', left_before: 'float | None', left_after: 'float | None', row: 'PersonRow | None', issues: 'tuple[int, ...]' = (), leaves: 'date | None' = None)#
- class perch.core.cut.Side(left: float | None, budget: float | None, clear_p50: float | None, headroom: float | None, signal: str | None, people_left: dict[str, float], old_row: bool = False)#
The team-level figures on one side of the change.
- perch.core.cut.compare(before: Money | list[dict], after: Money, board: Board, estimates: dict[str, Estimate], rates: Rates, people: dict[str, str], changes: Sequence[PlanEntry] = (), iterations: int | None = None, seed: int | None = None) Cut#
Before (a Money, or a recorded history week) against after, on this board.
Both sides share one seed, so a project with no seed: does not show simulation noise as part of the change.
- perch.core.cut.parse_change(flag: str, names: set[str], span: YearSpan, *, leaves: bool = False) PlanEntry#
–leaves NAME:YYYY-MM-DD (0 FTE from then) or –fte NAME:YYYY-MM-DD:FTE.
- perch.core.cut.side_from_history(week: list[dict]) Side#
A recorded perch board week; a figure it never recorded is None.
perch weekly: one markdown block per person, from this run plus history.
Draft text for gitboard’s digest or Budgie’s per-person mail to carry. Pure logic: no click, no rich, and nothing is written or sent. A trend is only shown with MIN_WEEKS of the person’s own earlier weeks behind it; below that the block says how many it has instead.
- perch.core.weekly.weekly(*args, **kwargs) str#
- perch.core.weekly.weekly_blocks(board: Board, money: Money, rates: Rates, rows: list[PersonRow], accuracy: list[PersonAccuracy], estimates_given: bool, people: dict[str, str], history: list[dict], only: str | None = None) list[dict]#
perch watch: has anyone moved away from their own normal? For the lead only.
Every signal compares a person with their own trailing weeks, never with anyone else, and says what sample it rests on. A signal flags only when it was out of line in OUT_OF of the last RECENT weeks, so one odd week never flags. A flag is a prompt for a conversation, not a verdict. The watch never goes into the weekly drafts, the emails or any report. Pure logic: no click, no rich, nothing written or sent.
- class perch.core.watch.PersonWatch(name: 'str', signals: 'tuple[Signal, ...]')#
- class perch.core.watch.Signal(name: 'str', flagged: 'bool', text: 'str', sample: 'int')#
- class perch.core.watch.Watch(title: 'str', people: 'list[PersonWatch]', weeks: 'int')#
- property thin: bool#
no own-baseline signal can flag yet.
- Type:
Under MIN_WEEKS of history
- perch.core.watch.watch(board: Board, money: Money, rates: Rates, estimates: dict[str, Estimate], people: dict[str, str], history: list[dict]) Watch#
Each person in people:, in name order, against their own figures.
Trend: read-only arithmetic over the team rows of history.jsonl.
Nothing here runs a simulation, reads a person row or ranks anyone; every figure is a recorded figure or the difference of two recorded figures.
- class perch.core.trend.Change(before: 'str', after: 'str', signal: 'tuple[str, str] | None', deltas: 'dict[str, float]')#
- perch.core.trend.change(rows: list[dict]) Change | None#
The team row of the last two recorded weeks; None when fewer than two.
- perch.core.trend.changes(rows: list[dict]) list[Change]#
Every consecutive pair of recorded team weeks, oldest first.
- perch.core.trend.describe(name: str, change: Change | None) str#
apollo W39→W40: YELLOW→RED, headroom −$12,000; “” when nothing changed.
- perch.core.trend.series(rows: list[dict], key: str, window: int = 8) dict[str, float]#
The team’s key by week, oldest first, the last window weeks.
- perch.core.trend.spark(values: Sequence[float]) str#
One bar per value, scaled min to max; flat is all mid bars.
- perch.core.trend.table(rows: list[dict], window: int = 8) dict#
The team row of the last window recorded weeks as a table block.
- perch.core.trend.team_lines(rows: list[dict]) list[str]#
The drill text: the latest week’s figures, then headroom (and pace, from when it was recorded) by week.
One load of everything a project’s files say, for the slow tier.
The pane, the tape and the events calendar all read config, history, the Budgie project, the board dump and the failure record. Money costs about 0.4 s cold, so it is read once here and shared. Each source is read on its own: one that fails is named in errors and the rest still load. The exception text is never kept, since a loader’s message is built from file contents.
- class perch.core.sources.Sources(config: 'Config | None' = None, rows: 'list[dict]' = <factory>, money: 'Money | None' = None, board: 'Board | None' = None, failure: 'dict | None' = None, errors: 'tuple[str, ...]'=())#
- perch.core.sources.stamp(home: Home, name: str) float#
The newest mtime among the files load reads (stat calls only), so the slow tier can skip a project nothing has touched. -1 when the config will not read.
- perch.core.sources.unreadable(source: str) str#
The one line a consumer shows for a source that would not read.
Budget revisions and staffing changes, with no name, hours or dollars.
Events and the tape both list these. quarterly._staffing carries names and costs for the quarterly report only; this module reads the same plan and keeps the team level. A row on the year’s first day is the starting team, not a change.
- class perch.core.moves.BudgetMove(day: 'date', before: 'float | None', after: 'float')#
- class perch.core.moves.StaffMove(day: 'date', kind: 'str', fte_before: 'float', fte_after: 'float')#
- perch.core.moves.budget(money: Money, start: date, end: date) tuple[BudgetMove, ...]#
Revisions dated in [start, end] after the year’s first day that changed the amount.
- perch.core.moves.staffing(money: Money, start: date, end: date) tuple[StaffMove, ...]#
plan.csv changes dated in [start, end]; ties keep plan.csv entry order.
The detail pane’s data: the headroom history, Budgie’s burn series, the freshness of each input. Read only, team level, no new arithmetic; every dollar on the chart comes from Budgie’s burn_series through Money.burn.
- class perch.core.detail.Detail(project: 'str', headroom: 'dict[str, float]', burn: 'BurnSeries | None', as_of: 'date | None', dump_on: 'date | None', history_week: 'str | None', pace: 'float | None', net_scope: 'int | None', non_labor: 'float', note: 'str')#
- perch.core.detail.lines(d: Detail, upto: str | None = None) list[str]#
The pane’s text, chart excluded (the TUI draws that from d.burn).
perch events: what is coming, soonest first, at the team level.
Budget and staffing changes come from moves (no name, no hours); milestones are the open issues grouped by (milestone, due); quarter ends, the year end and holidays are Budgie’s calendar. Pure over the loaded sources: nothing is sent, no person is named and nothing is ranked. Chronological order only.
- class perch.core.events.Calendar(events: 'tuple[Event, ...]', notes: 'tuple[str, ...]', errors: 'tuple[str, ...]')#
- class perch.core.events.Event(day: 'date', project: 'str', kind: 'str', what: 'str', n: 'int | None' = None)#
- perch.core.events.build(project: str, money: Money, board: Board | None, today: date, days: int = 30) tuple[Event, ...]#
Events in [today, today + days], inclusive; an open overdue milestone has no lower bound.
- perch.core.events.span_note(project: str, money: Money, today: date, days: int) str#
The dim note for a window that runs past Budgie’s year; ‘’ when it does not.
The tape: what changed, newest first, from files only.
One line per event: a stoplight flip or figure move between recorded weeks, a budget or staffing change, the latest hours reading, the board fetch, the issues closed on a day, a failed Monday step, an unreadable source. Team level: no person, note or exception text. Never reads the watch, a person, type or accuracy row.
- class perch.core.tape.TapeEntry(day: 'date', project: 'str', kind: 'str', text: 'str')#
As-of browsing: pure arithmetic over the week keys of history rows.
A week is an ISO string like “2026-W37”; None means live. Nothing here reads a person row or a file.
- perch.core.asof.resolve(text: str, recorded: list[str]) str | None#
A typed target to a recorded week; None is live.
- perch.core.asof.step(recorded: list[str], current: str | None, delta: int) str | None#
Move delta positions along recorded[:-1] + [live], clamped.
- perch.core.asof.team_as_of(rows: list[dict], week: str) dict | None#
That week’s team row, never the nearest one.
- perch.core.asof.weeks(rows_by_project: Iterable[list[dict]]) list[str]#
Every week any project recorded a team row, oldest first.
Alert rules: the lead’s own thresholds over the recorded team rows.
alerts: in config.yaml, each when: FIGURE OP NUMBER and an optional project. Pure over history rows: team level, read only, nothing sent, no ranking. A missing figure is “no data” (true=None), never false.
- class perch.core.alerts.Rule(index: 'int', text: 'str', figure: 'str', op: 'str', value: 'float', project: 'str | None')#
- class perch.core.alerts.State(rule: 'Rule', project: 'str', week: 'str | None', figure: 'float | None', true: 'bool | None', fresh: 'bool')#
- perch.core.alerts.evaluate(rules: Sequence[Rule], name: str, rows: list[dict], today: date | None = None) list[State]#
One State per rule that covers name, from the team rows of history.
- perch.core.alerts.lines(states: Sequence[State]) list[str]#
One line per rule and project: its state and the figure.
- perch.core.alerts.parse(data: object, projects: Sequence[str], source: str = 'config.yaml') tuple[Rule, ...]#
The rules of an alerts: value; every error names the key.
The TUI command line’s grammar: text in, perch argv out.
Nothing here runs perch or reads a file; parse only splits and checks the words, so the TUI can show a bad line as a toast before spawning anything. perch cut still validates names and dates itself.
- class perch.core.command.Command(project: 'str | None', args: 'tuple[str, ...]')#
- perch.core.command.amount(token: str) float#
700k, 1.2M, $700,000 -> a positive number.
perch quarterly: one project’s quarter for the funder, rebuilt from sources.
Hours come from Budgie’s readings, the budget and plan from Budgie’s snapshot, the forecast from Budgie’s own engine, and the work from gitboard’s dump – never from perch’s history.jsonl, which only starts when perch was first run. Team level only: no person’s name sits next to a number except in staffing changes, which are facts from plan.csv.
- class perch.core.quarterly.IssueMiss(key: 'str', estimated: 'float', modelled: 'float', dollars: 'float | None')#
- class perch.core.quarterly.Position(spent_quarter: 'float | None', spent_year: 'float | None', budget_start: 'float | None', budget_end: 'float | None', revisions: 'tuple[BudgetRevision, ...]', flat: 'bool', p10: 'float', p50: 'float', p90: 'float', signal: 'SignalResult | None', non_labor: 'float', booked_quarter: 'float | None' = None, planned_quarter: 'float | None' = None)#
- class perch.core.quarterly.Quarter(project: 'str', name: 'str', start: 'date', end: 'date', year_start: 'date', through: 'date', to_date: 'bool', as_of: 'date | None', read_to: 'date | None', position: 'Position', misses: 'tuple[LabelAccuracy, ...] | None', in_progress: 'tuple[tuple[str, int, int], ...]', issue_misses: 'tuple[IssueMiss, ...]', weeks: 'tuple[Week, ...]', blocked_days: 'int | None', reopened: 'int | None', staffing: 'tuple[Staffing, ...]', hours_note: 'str | None', board_note: 'str | None', board_span: 'tuple[date, date] | None' = None, previous: 'Week | None' = None, previous_blocked: 'int | None' = None)#
- property board_partial: bool#
The dump covers only part of the quarter (so far).
- property closed_span: tuple[date, date] | None#
First to last day of the weeks whose closes are counted.
- property net_scope: int | None#
Issues opened less issues closed in the quarter; None without creation days.
- property total_per_issue: float | None#
Hours over issues, from the weeks where both are known.
- class perch.core.quarterly.Staffing(name: 'str', day: 'date', kind: 'str', fte_before: 'float', fte_after: 'float', hours: 'float', dollars: 'float | None')#
- class perch.core.quarterly.Week(label: 'str', start: 'date', end: 'date', closed: 'int | None', hours: 'float | None', opened: 'int | None' = None)#
- perch.core.quarterly.build(board: Board | None, money: Money, estimates: dict[str, Estimate], rates: Rates | None, people: dict[str, str], quarter: str, today: date, project: str) Quarter#
The quarter’s report. board is None when there is no dump yet.
- perch.core.quarterly.last_complete_quarter(span: YearSpan, today: date) str#
The year’s last quarter that is over by today; Q1 when none is yet.
- perch.core.quarterly.parse_quarter(text: str, span: YearSpan) tuple[date, date]#
2026-Q3 -> (Jul 1, Sep 30); FY27-Q1 -> (Oct 1, Dec 31, 2026).
The quarters are Budgie’s year cut in four, so the name must be that year’s: a calendar project takes 2026-Q3, a fiscal one FY27-Q1.
- perch.core.quarterly.quarter_to_date(board: Board | None, money: Money, today: date) dict#
pace (booked over planned hours) and net_scope for the quarter holding today, or the latest reading if that is earlier: the history row’s figures, from the same window code as build and without its forecast. Both None outside the Budgie year.
A Quarter as markdown and as an Outlook draft (.eml). Nothing is sent.
Both come from one list of blocks (heading, paragraph, table), so the text part and the HTML part of the draft always say the same thing. The HTML follows the same Outlook rules as Budgie’s emails: tables and inline styles only, since Outlook renders HTML through Word.
Commands#
perch suite#
perch tui # starts the suite when tmux is installed
perch suite # the same, explicitly; -p apollo opens on apollo
perch tui --no-suite # perch alone
perch, Budgie and gitboard open side by side. B and G in perch (and P,
B, G in the others) jump between them at once, and each stays where you
left it. Close the terminal and the suite keeps running: perch tui again
picks it up. q in perch closes everything. It runs on tmux, which you never
have to touch (brew install tmux once). If the suite ever wedges (an app
hangs, or tmux was upgraded while it ran), tmux -L pi kill-server resets it;
perch tui then starts fresh.
Inside the suite, without tmux, or with --no-suite, perch tui is just the
perch TUI. perch suite without tmux says needs tmux: brew install tmux. A project whose config
is broken opens perch alone; the others open on the first hop.