| name | mode-blueprints |
| description | Internal blueprints of the standalone modes — Daily Standup, Retro (loopback server/tunnel/security), Performance, Reporting, and Roadmap Intake — their pipelines, stores, schema versions, delivery channels, and exports. Use when modifying src/yeaboi/standup/, retro/, performance/, reporting/, roadmap/, or adding a new mode (they all follow this blueprint). |
Mode Blueprints
All four modes follow one blueprint: a self-contained package (engine + store + render/export), frozen-dataclass artifacts in agent/state.py (all fields defaulted), a SQLite schema bumped in sessions.py, a TUI page via the shared component system, and standalone pipelines (NOT LangGraph nodes) that follow the node parse → fallback → format convention with one get_llm()/track_usage() call.
Adding a mode also means adding its discoverability tip: a FeatureTip in src/yeaboi/ui/shared/_tips.py (_FEATURE_TIPS) keyed by the capability name, with a mode_key when it owns a _MODE_CARDS card (so the welcome-screen g key jumps into it) and is_new=True for a release or two. TestTips in tests/unit/test_surface_parity.py fails until the tip exists (or a TIP_EXEMPT entry with a reason is recorded).
It also means adding a saved-sessions hub, because every one of these modes stores its runs: the card lands on _run_mode_hub(...) (ui/mode_select/__init__.py), which takes the mode's behaviour as injected callables — load_runs → RunSummarys, make_detail (or open_snapshot when the snapshot needs its own buttons), files_export, get_document, delete_run, run_new — and the card key is registered in SAVED_SESSION_HUBS. Render the snapshot through the mode's own screen builder so a saved run looks like the live view. TestSavedSessions fails until the hub exists or a SAVED_SESSIONS_EXEMPT reason is recorded.
Daily Standup Mode
The src/yeaboi/standup/ package implements a daily scrum that detects team activity, scores sprint progress, and delivers a summary — runnable from the TUI or headlessly on an OS schedule.
Design choice — standalone pipeline, not a LangGraph node. engine.run_standup() calls get_llm()/track_usage() directly following the node parse → fallback → format convention, but is not a compiled graph — the scheduled headless run must be fast and checkpoint-free. Activity gathering + confidence are deterministic function calls; the LLM is used only to synthesize prose (one call).
The deterministic middle of the pipeline is one pure function. Everything between collection and the LLM prose call — identity closure, roster filter, automation filter, coverage, grouping, insights, habits/relatedness, confidence, the per-member skeletons — is aggregate.aggregate_standup(inputs) (src/yeaboi/standup/aggregate.py). The practice adjudicator is the one LLM interleave, hoisted out by a two-pass protocol: pass 1 returns adjudication_cases, the engine runs adjudicate.py, pass 2 repeats identical inputs plus dropped_case_ids.
Pipeline (engine.run_standup(session_id)): load session state + StandupStore.load_config → collector.collect_recent_activity (fan-out, graceful per-source skip; the engine's _skipped_sources hands it the REASON each source is missing, since only the engine can tell "never ticked" from "no credentials" from "ticked with an empty scope" — every skip is narrated as a progress step and listed in the report's Not scanned panel, but only unmet_sources (asked for, not delivered) reaches a ⚠ notice or a broadcast surface, or a Jira-only team reads the same five-source apology in every standup forever) → roster filter + automation.partition_automated (service-hook/bot comments posted under a member's identity — scanner markers, [bot] metadata, near-identical burst clusters — are excluded from credit with a Notices line; tune via automation_markers / automation_handling config) → insights.detect_blocker_signals + insights.yesterday_context (deterministic blocker evidence — blocked-ish ticket statuses, PRs open across two standups, comment churn — plus each member's previous-standup context from StandupStore.get_previous_report) → habits.detect_practices (deterministic engineering-practice signals — untracked work/docs, board not updated after a merge, WIP sprawl, oversized PR, commits with no PR, thin commit messages; ticket-shaped rules are suppressed entirely when ticketing coverage is failed/not_configured, capped at 3/member, NEVER fed to the LLM, tune via habit_detection / habit_rules config; before reporting a change as untracked it goes through relatedness.py, which matches it against the description/acceptance-criteria/definition-of-done of every ticket in the window PLUS the open tickets collector fetches into bundle.reference_tickets — matching may only ever SUPPRESS a signal and the matched key is never surfaced, which is also what makes the optional adjudicate.py LLM pass safe: it returns ids to drop, tune via habit_ai_match; practice_feedback.py is the team's own thumbs up/down on a signal, the same suppress-only shape again — a verdict is cast on a signal but stored per change (habits.change_handle, hung off PracticeSignal.handles and kept out of the export payload), so a thumbs-down removes that signal from the stored run, excuses each change behind it forever via , and rides into the adjudication prompt as few-shot calibration alongside any thumbs-up; ledger lives in , written by from the TUI Practices button, the MCP tool, and on a correctable share — the last is the ONLY export that can make a request at all, though (both standup shares are editable now, and one document cannot have two writers; a verdict op inside the edit log is what would restore it): swaps for (, nothing else differs — the same policy an editable artifact uses), the payload gains so written exports never render controls that would do nothing, and the handler re-renders the run so later readers get the corrected report; never correctable while anonymized) → + (deterministic sprint day + burn-down; prior-run feeds a day-over-day trend: delta/ on the report, sustained 3+-drop slides dampen pct 10%) → self-reported updates verbatim / others summarized by one LLM call (yesterday context + blocker signals ride in the member payload; the model writes per-member /, clamped so no note survives without a real previous report and no detected signal is dropped from ) → → → .
Recent-activity helpers are plain functions (not @tool) on each tool module: jira_recent_activity, azdevops_recent_activity, github_recent_commits/github_recent_prs, confluence_recent_pages, tools/local_git.local_git_recent_commits, plus *_active_sprint_progress for burn-down. All return normalized list[dict] and degrade to [] when there is nothing to read; a source that exists but cannot be read raises StandupSourceError instead (GitHub: 401/403/404/rate limit), because an empty list is indistinguishable from a quiet day. The collector lazy-imports them (optional SDKs).
Code scope is picked by container, not by repository (code_scope.py). Azure DevOps has always been picked by project — one tick covers every repo inside it — and GitHub now matches: the picker lists owners/organisations (discover_github_owners → tools/github.github_list_owners, which unions the authed login, user.get_orgs(), and the owners of visible repos so a fine-grained PAT that cannot list orgs still fills the screen), saved as github_owners, and expand_github_owners resolves them to concrete repos per run via github_analysis_inventory(..., include_trees=False) — so a repo created this morning is in tonight's standup with nothing to re-tick. Discovery looks back max(window, 14) days because pushed_at does not move for a review-only day, and the fan-out is capped (_MAX_REPOS_PER_OWNER=10, _MAX_REPOS_TOTAL=30, most-recently-pushed first) for the same reason azure_devops._MAX_ACTIVITY_REPOS exists — 3 API calls per repo on the critical path of a run whose whole lead time is 10 minutes; the per-owner cap stops one busy org starving the others and every truncation is reported. github_repositories survives as the narrow "these exact repos" scope (it carries the legacy STANDUP_GITHUB_REPO) and is unioned with the expansion; nothing derives owners from it behind the user's back — not the store, not the v28 migration, and not the picker's pre-tick — because turning acme/api into acme would silently widen a standup to a whole org. The picker's save is the mirror rule: repositories are preserved unless a chosen owner already covers them, so a pinned repo, a deliberately narrow scope, and a repo whose owner never surfaced in discovery all survive a Configure pass. A bare GITHUB_TOKEN is now scope enough: default_code_scope() enables GitHub on the token alone, and the collector — not _resolve_code_scope — resolves the never-configured case to every visible owner. Scope resolution stays network-free deliberately: it runs on every path, so discovering there blocked the standup's critical path on a GitHub call and put a live 401 inside the unit suite. The collector already runs threaded with progress and folds failures into bundle.errors, so a token that can list nothing says so instead of reporting a quiet day. GitHub and Azure DevOps are separate screens in the TUI (, own heading each — "Choose GitHub organisations" / "Choose Azure DevOps projects"), and picking any GitHub owner(s) opens a third, GitHub-only screen (, an uncapped-by-activity sibling of — only archived repos are dropped, capped for picker sanity at /) listing every repo in the chosen owner(s), ; unticking one adds it to — an exclude list, not an include list, so a repo created next week is still scanned with nothing to re-tick, and 's param drops those slugs before the per-owner/total caps so an excluded repo frees its slot for another. The owner picker's pre-tick is keyed off whether GitHub itself has ever been scoped ( or a prior narrow pin), the standup's overall flag: adding GitHub to a standup already configured for Azure-only used to pre-tick nothing (Azure's was mistaken for "GitHub already scoped too"), forcing a fully manual walk — now it pre-ticks every discovered owner, same as a first-ever configure.
Scheduling (scheduler.py) is OS-native so standups fire when the app is closed. The user configures the standup time (when the meeting happens) + lead_minutes (default 10); the job fires standup_time − lead (run_time() helper). On macOS it opens a Terminal at run time (a launcher script under ~/Library/Application Support/yeaboi/ + osascript) so the run can prompt; on Linux it's a crontab entry that runs headless. Both invoke yeaboi --standup-run --standup-interactive --standup-session <id>; interactive.py prompts only when a TTY is attached, else falls back to headless. Windows is unsupported (graceful message).
Warnings, not empty content (errors.py + engine): activity helpers raise StandupSourceError on a failure the user must act on (401/403, plus 404 and rate limiting for GitHub) → collector records ActivityBundle.errors per source, so one unreachable repository reports itself and the rest still collect. The engine also checks config.is_llm_configured() and catches LLM auth/billing errors (no longer re-raised) — all fold into StandupReport.warnings, rendered as a ⚠ Notices section in the dashboard and delivery output. Generate also prompts for the user's own update first (STANDUP_USER_NAME, default "Me").
Delivery (delivery.py) is stdlib-only: Terminal (Rich), Desktop (osascript/notify-send), Slack (urllib webhook), Email (smtplib). deliver() fans out and returns per-channel status; one channel failing → status="partial", never raises.
Persistence: store.py defines _STANDUP_SCHEMA (schema v6 in sessions.py) with standup_config / standup_history / standup_updates. StandupReport/MemberUpdate are frozen dataclasses in agent/state.py (all fields defaulted for backward-compat).
TUI: the magenta Standup card → the saved-standups hub (_run_standup_hub, with a fixed Set up a schedule card driving the option-list wizard _run_standup_schedule_wizard) → _build_standup_screen + _run_standup_page for a live run, with Generate / Review / Team / Sources / Anonymize / Identity / Back actions (Identity = repo path + aliases; Sources re-opens the code-scope picker, which otherwise had no door after first setup; schedule setup lives on the hub). Generate opens with a saved-setup gate (_standup_saved_setup → _run_standup_saved_setup_confirm → _build_standup_saved_setup_screen): once every applicable step has been confirmed on an earlier run — the roster_configured / code_scope_configured / documentation_scope_configured flags, each required only when its integration exists in env — it summarises the saved answers — trackers, roster, named code scope, docs, and a non-gating Last run line from standup_history — as a two-column card grid (the Analysis setup-review chrome, re-branded), and offers Use saved (skip straight to the update prompt) / Change (walk the five pickers as before) / Back. Rows cross to the builder as plain (label, value) strings; glyphs, colours and the card/list choice are the builder's. It never changes what the engine reads; the engine already resolves saved config on its own. Logs go to ~/.yeaboi/logs/standup/; readable output (Markdown + HTML) is auto-saved to ~/.yeaboi/exports/standup/<project>/ every run and via the Export button (export.py, paths.get_standup_export_dir).
Retro Mode
The src/yeaboi/retro/ package implements a collaborative sprint retrospective: the host opens the Retro page and teammates add sticky cards to four grids — What went well, What didn't go well, Action items, Demos — from their own browsers, wherever they are.
Design choice — browser board, stdlib-only. A retro needs the whole team, but the app is a local terminal tool. So the host's TUI starts a small http.server.ThreadingHTTPServer (NOT FastAPI/Flask — matches the stdlib-only ethos of standup/delivery.py) bound to 127.0.0.1, and — as soon as the Cloudflare tunnel in front of it is up — shows a join code + participant URL. Teammates open the URL in any browser (no install) and POST cards; the page (page.py) polls every 2 s. There is no new dependency. There is deliberately no LAN address: an earlier version bound 0.0.0.0 and advertised the Wi-Fi IP, which worked for one room, exposed the port to the whole network, and got pasted to remote people anyway.
Live board + thread safety (board.py): RetroBoard is the single source of truth during a session — a threading.Lock-guarded card list with a _revision counter. The background HTTP threads call add_card; the TUI render thread calls snapshot()/cards_by_grid() each frame. Readers copy inside the lock and render outside it; the lock never wraps a Rich render or JSON dump. No extra TUI-side thread is needed — the existing frame-timed read_key loop re-renders from snapshot() every frame, so cards appear within one frame. RetroCard/RetroReport are frozen dataclasses in agent/state.py (all fields defaulted).
Security (server.py): access is gated by a per-session secrets.token_urlsafe(16) token checked with sharing/access.secret_equal (constant-time, and total over non-ASCII input — bare compare_digest raises TypeError on a non-ASCII str, and every credential here comes off the wire); GET / serves the harmless page but every /api/* call requires the token. POST body capped 4 KB, card text ≤500 / author ≤60. The server binds loopback only and is reachable solely through the tunnel's HTTPS, so there is no plaintext hop and nothing on the local network can reach it. Card text is escaped on browser render (textContent), in exported HTML (html.escape), and framed as untrusted data in the LLM prompt. Every served document's response headers and its Content-Security-Policy come from web/security.py (send_document, DOCUMENT_HEADERS, BOARD_CSP) — no handler writes its own, so the board, the gate and a shared artifact cannot drift apart on this. Concurrency: daemon_threads=True; shutdown() is called from the TUI thread (never the server thread — deadlock), then server_close(); every response sets Content-Length (HTTP/1.1 keep-alive).
AI action items (engine.py): generate_action_items(board) makes one get_llm() call (prompt in prompts/retro.py, ARC framework) from the "didn't go well" cards (+ selectively "went well"), following the standup parse → fallback convention — an auth/billing error becomes a status message + deterministic fallback, never a crash. Added cards get origin="ai" and are badged in both the TUI and browser.
Persistence & export: store.py defines _RETRO_SCHEMA (schema v7 in sessions.py) with one retro_history table; the board is flushed via RetroStore.record_run in a finally on page exit. export.py writes Markdown + HTML to ~/.yeaboi/exports/retro/<project>/ (paths.get_retro_export_dir), reusing html_exporter._CSS.
Live web interface (page.py, one self-contained offline page): teammates get emoji reactions on cards (fixed REACTION_EMOJIS set, click to toggle), drag to reorder/move cards between grids, edit/delete their own cards (author-only), a shared countdown timer (presets + custom, synced via the server clock, confetti + Web-Audio alarm on finish), a join modal with an avatar picker + 🎲 random-name generator (renamable later via the #me pill), a theme switcher (5 [data-theme] palettes), and Web-Audio-generated music (ambient/lofi/focus/hip-hop/jazz, no files, offline) with a live AnalyserNode visualizer. The header is a compact toolbar: brand + card count, a distinct "you" me-chip, an others-only overlapping-avatar presence stack (the current user is filtered out so they're never shown twice), a 👥 room count that opens a left-anchored roster popover listing everyone present (you tagged "you", live "typing…" tags), and small icon buttons (♪ Music / ⏱ Timer / ◑ Theme / Invite) that each open a popover (.pop, one open at a time, closed on click-outside/Esc) holding that control's UI — Theme is a row of colour swatches, the running timer's MM:SS shows inline on its button. Per-grid typing indicators sit under each column. All of it is driven by a unified /api/state polled ~1.2 s — the page POSTs /api/presence (heartbeat + fetch in one round-trip) and renders the returned {cards, reactions, presence, typing, timer}; each card carries a mine flag (owner == viewer pid) that drives the ✎/✕ controls without ever putting raw pids on the wire. Reactions/presence/typing/timer/ownership are lock-guarded board state (board.py); REACTION_EMOJIS/AVATARS are server-validated (participants untrusted). Card mutations go through /api/card/{edit,delete,move} (edit/delete owner-checked server-side; move open to all). Reactions fold into at report time (shown in MD/HTML exports; fed to the AI as a priority hint). The big CSS/JS lives in plain / module strings filled by placeholder ; is E501-exempt in as an embedded asset.
Joining & token security (server.py): the served / page is token-free — GET / is unauthenticated, so the token is never baked into the HTML (it would leak to anyone who reaches the board, which over a public tunnel is anyone with the link). The client reads the token from its own URL ?token=, or a teammate opens the bare host address and types the short join code into the code-entry gate → POST /api/join (unauthenticated; compare_digest(code, join_code) → returns the token). RetroServer.join_code (an 8-char unambiguous code, shown in the TUI as display_code) is the gate credential; the 128-bit token still guards direct URLs/QR.
The invite is one URL. sharing/access.invite_url(share_url, join_code) composes https://host/#code=XXXX-XXXX and is the only place that format exists — every Copy Invite button, /api/invite's inviteUrl, and GET /api/qr (token-gated, segno) carry exactly it, and the shared JoinGate reads the code back out of the fragment, prefills and auto-submits. So a clicked link or a scanned QR lands on the board without anyone typing; the gate is still there for a code passed by voice. It replaced a two-line clipboard payload (URL + Access code: …) that any paste target flattening a newline turned into one 404ing path. The code rides in the fragment, never ?code=, because a fragment is never sent to the origin — keeping it out of cloudflared's log and out of the gate's outbound Referer. The flip side: nothing server-side can see an invite click, so all stale-link protection is client-side, in JoinGate — strip-before-request, a once-per-mount latch, a no-token check, and a localStorage dead-code memo (local, not session: a chat link opens a fresh tab). Those four are what keep a restarted board's old link from walking an IP into JoinLimiter's eight-failure lockout.
Per-visitor limits behind the tunnel (sharing/access.client_key): handler.client_address[0] is 127.0.0.1 for every remote participant, because cloudflared connects from the same machine. Keyed on it, JoinLimiter was one global bucket (eight wrong codes from anyone locked out the whole team, repeatably — a DoS, not a brute-force defence) and EventHub.MAX_PER_IP = 4 capped the entire board at four long-poll holders. client_key(handler, trust_forwarded=...) prefers cloudflared's CF-Connecting-IP, and trusts it only while a tunnel is live (bool(server.public_url)) — off-tunnel the header is just a string a client chose. It is validated as an IP before use, and JoinLimiter bounds its table, evicting un-blocked entries first so a flood of spoofed addresses cannot flush an active lockout.
Both the QR and /api/invite go through sharing/access.participant_url(headers, fallback, public_url), which returns server.public_url when set and otherwise derives from the request — necessary because the host's own browser arrives on 127.0.0.1, and an invite built from that request would hand a teammate their own machine.
Joining — Cloudflare tunnel (tunnel.py): the loopback server reaches nobody, so the tunnel is not an extra — it is the way in, and it therefore starts by itself when the board opens rather than on a button. A Cloudflare quick tunnel (cloudflared tunnel --url http://localhost:<port>) exposes a public https://…trycloudflare.com URL forwarding to the token-gated server. It's genuinely zero-setup: no Cloudflare account/token (unlike ngrok, which forces a per-user authtoken), so the app owns the whole flow — ensure_cloudflared() downloads the platform binary on first use to ~/.yeaboi/bin/ (cached; honours a cloudflared already on PATH or CLOUDFLARED_PATH — neither of which is checksum-verified, so YEABOI_CLOUDFLARED_STRICT=1 refuses both and accepts only the managed copy, which is re-verified against a digest recorded at install time on every launch). The child runs with an allowlisted environment (_CHILD_ENV_KEYS): load_dotenv() puts every API key in os.environ, and — the reason this is a control rather than hygiene — cloudflared is itself configured by env vars, so a stray TUNNEL_LOGLEVEL=debug would make it log request URLs and all headers, and this app's credentials ride in the query string. --loglevel info and --metrics 127.0.0.1:0 are pinned for the same reason, and start_new_session=True keeps the child's lifetime stop()'s to control. Setup runs on a worker thread (download + handshake + the DNS-propagation gate are slow, ~10-75 s: readiness gates on cloudflared's Registered tunnel connection line — the URL banner alone would serve Cloudflare error 1033 — and a launch killed by a broken resolver's incomplete region SRV answers is retried once with --region us inside the same 45 s budget) while the frame-timed loop shows progress and the join block reads Participant link: preparing…; on success the worker calls server.set_public_url() so /api/invite and the QR answer with the tunnel URL. The tunnel is torn down in the page's finally. Failure never raises — the status line explains it and a Retry Link button appears; the host's own board still works over 127.0.0.1, but nothing is shareable until a tunnel comes up. The browser page uses relative fetch URLs, so it is indifferent to which address it was reached on. : also arms a for (default 60, 0 = never) — one enforcement point shared by Retro, Poker, and the generic Share Online flow, so a forgotten open board doesn't stay internet-reachable forever. On expiry the tunnel stops itself, un-publishes via , and calls an callback that reuses the same state as a failed start (Share Online instead collapses to its own terminal "Back only" state, since it has no retry button). A quick tunnel gets a fresh random hostname on every launch, so once one expires the invite already sent to the table is gone for good — lets the live boards' status line warn the host in the last five minutes before that happens, giving them time to wrap up or re-share rather than the link just dying mid-ceremony; Share Online skips the warning since a static output has no synchronous audience to lose.
The verified-users tier (sharing/identity.py, sharing/access_tunnel.py, sharing/tunnel.open_tunnel): YEABOI_SHARE_MODE=access swaps the quick tunnel for the host's own named tunnel on a hostname they control, fronted by a Cloudflare Access application. A quick tunnel cannot carry Access at all — policies attach to a hostname in your zone, and a quick tunnel's is random in Cloudflare's. It is locally-managed (credentials file + a generated ingress), not the dashboard's --token form, because every server here picks its port at bind time and a remotely-managed tunnel needs the port written down in advance. open_tunnel(port, surface=…) is the one place the tier is decided; the three call sites grow no branch. Three properties matter and each has tests: (1) edge enforcement is re-verified locally — AccessVerifier checks signature/aud/iss/exp against the team's JWKS with algorithms=["RS256"] (the line that kills alg: none and HS256 confusion), returns None for every failure, and fails closed on a cold key cache; preflight() refuses to publish at all when the keys are unreachable. (2) Which requests must verify is keyed on the Host header, never on client_address — cloudflared connects from 127.0.0.1, so the socket cannot tell the host's own browser from a teammate; loopback stays token-gated, the published hostname and anything unrecognised must verify. That rule is only sound because the generated ingress pins originRequest.httpHostHeader, so AccessTunnel.start() runs cloudflared tunnel --config … ingress validate before launching (an unknown ingress key is treated as merely unused at run time, which would leave the pin silently unapplied). (3) It never falls back to a quick tunnel — a partial config, a missing extra, an unreachable JWKS or a failed launch all leave the board on loopback with a named reason. Also: enforce_identity overwrites the client's pid/author with the verified subject (namespaced cf:<sub>, so it cannot collide with a browser-minted UUID), which is what stops one token holder editing another's cards; _admin_authed ignores the body's admin string over the tunnel and answers from by exact set membership. One named tunnel serves one hostname at a time — refuses a second board rather than let Cloudflare route requests to whichever connector answers first, which is why the per-surface overrides exist. The join code is kept as a second factor; the tier does not currently auto-satisfy it, because is built once at server start and so cannot carry a per-request verification flag.
TUI: the teal Retro card → _build_retro_screen + _run_retro_page in ui/mode_select/, with Copy Invite / Copy Host Link / Generate Action Items / Export / Anonymize / Close actions (plus Retry Link when the tunnel failed). Targets the most recent session; logs go to ~/.yeaboi/logs/retro/. Configure the server port with RETRO_PORT (default 5173, walks upward if busy).
Performance Mode
The src/yeaboi/performance/ package helps a team lead manage each engineer with three connected, LLM-backed workflows. It follows the standup/retro blueprint exactly: a self-contained package (engine + store + render/export), frozen-dataclass artifacts in agent/state.py, a SQLite schema bumped in sessions.py, a TUI page via the shared component system, and a gather_performance_context() reader that feeds Planning & Analysis.
Design choice — standalone pipelines, not LangGraph nodes. Each workflow (engine.run_one_on_one_prep, complete_one_on_one, run_six_month_review) is one deterministic gather step + a single get_llm()/track_usage() call following the node parse → fallback → format convention. An LLM auth/billing error is never re-raised — it becomes a warnings entry + a deterministic fallback artifact, so the page always renders.
Roster from Jira/AzDO (roster.py): fetch_roster() derives the engineer list from the assignees who actually did work (reuses jira_recent_activity/azdevops_recent_activity), not the plan's team-size number, and carries each member's tracker identity/email through as alias seeds. Graceful [] when no tracker is configured.
Evidence is gathered from every mode, per engineer (evidence.py, modelled on poker/context.py — one graceful entry point that never raises, plus pure helpers). gather_engineer_evidence() reads saved stores first, because they cost nothing: StandupStore.get_recent_reports() for the per-member code_summary/documentation_summary/self_report/blockers/practices standup already recorded; TeamProfileStore.load_with_examples() for contributor_stats and ai_adoption.member_practices (delivery, spill, cycle time, test/doc/ticket/description rates, AI markers); RetroStore for the cards they authored and their carried action items; PokerStore for their votes against the team's final points; ReportingStore for what shipped under their name; and activity.py:gather_engineer_activity() for tickets (split current/previous by the live sprint start date via standup sprint_context, now deduped by key). deep_scan=True adds ONE capped live standup.collector.collect_recent_activity scan over the stretch no saved standup covered, reusing the standup's own saved scope so sources are configured once — it costs API calls and is off by default.
Attribution runs through identity.py, which reuses the standup's alias closure (_normalize_author/_build_alias_map/_enrich_aliases_from_items) so a person's commits, wiki edits and retro cards attach to them under any handle. It deliberately never passes my_name — that branch folds in the local machine's git identity, which belongs to the lead running the tool, not the engineer being reviewed. Matching stays exact-normalized (no substring), so Sam never absorbs Samantha.
Absence of evidence is never evidence of absence. Every source returns a SourceCoverage(source, state, detail) using the standup's covered/partial/failed/not_configured vocabulary, including the case where runs exist but none named this engineer — an attribution gap, not an idle period. Coverage rides on all three artifacts as evidence_sources/evidence_coverage (stamped by engine._with_evidence after the LLM branch so the fallback artifact carries it too), renders in the TUI, Markdown and HTML, and the prompts carry a standing rule forbidding the model from inferring that an engineer did not do something from a source that was not scanned. The fields are NOT in artifacts/registry.py's editable list — computed coverage is not a reader's to edit.
The numbers stay numbers. evidence.py used to compute delivery stats, practice rates, poker and retro participation and render them straight into prompt prose, which is where they stopped. The extraction now happens once (_analysis_rows) and projects twice — _analysis_lines for the prompt, _analysis_metrics for PerfMetric records — so a figure quoted to the model is the figure a page draws. A metric with no sample is omitted, never emitted as 0: an engineer whose spill rate was never measured has not spilled 0%. Alongside them EvidenceGroup carries tickets and shipped items as ActivityEvidence rows — the shape standup already stores and Evidence.tsx already draws — grouped by source, never by claim, because nothing maps an LLM-written bullet to a specific PR and an invented citation would be indistinguishable from a real one. section_states lets an empty section say whether nothing was found or nobody looked. All four fields ride on OneOnOnePrep/SixMonthReview. The numbers and the rows (not section_states, whose keys are the prep's own sections) are carried forward onto OneOnOneRecord — it is the artifact that gets emailed, so it is the one that most needs to say what it was based on — but only from a prep within _CARRY_PREP_DAYS, and always with that prep's date in evidence_date. get_latest_prep knows nothing about which meeting a prep was for, so an unbounded carry prints a scan from months ago as facts about today.
No schema migration, but every field needs a reconstructor. Artifacts persist as whole-artifact JSON in report_json, so new defaulted fields need no column and no sessions.py bump. What they do need is a line in store._dict_to_prep/_dict_to_record/_dict_to_review — mask_artifact round-trips through those, so a field the reconstructor misses is silently dropped from every anonymized artifact. TestMaskingReachesEveryField guards it. PerformanceNote(engineer, date, text) is registered too, so a lead's note masks like everything else.
Three workflows:
- 1:1 Prep — from the full
gather_engineer_evidence picture (tickets, the code/docs/self-report evidence saved by past standups, practice signals, analysis metrics, retro and poker) plus the open action items of their last 1:1, produces OneOnOnePrep (talking points, feedback, goals, gaps, improvements).
- 1:1 Completion — the lead provides a transcript (file import or inline paste); produces
OneOnOneRecord (email summary + tracked action_items), emails it via SMTP (reuses standup config.get_smtp_*), and persists the actions so the next prep carries them (the Prep↔Completion loop closes via PerformanceStore.get_open_action_items).
- 6-Month Review — synthesises past 1:1s + the same cross-mode evidence over the review period + team ceremony history + lead notes + a competency framework into
SixMonthReview. The framework is the bundled performance/references/competency_framework.md by default, overridable with a lead's own template via PERFORMANCE_FRAMEWORK_PATH.
Feeds Planning & Analysis (context.py): gather_performance_context() mirrors agent/ceremony_history.py — team-wide, graceful, distils per-engineer open 1:1 actions + review growth areas into a markdown block injected into the analyzer (performance_context param) and sprint planner (via ScrumState._performance_context). Only already-summarised signals cross the boundary — never raw transcripts.
Persistence: store.py defines _PERFORMANCE_SCHEMA (schema v8 in sessions.py) with performance_one_on_ones / performance_reviews / performance_notes. EngineerRef/EngineerActivity/OneOnOnePrep/OneOnOneRecord/SixMonthReview are frozen dataclasses in agent/state.py (all fields defaulted for backward-compat). Readable output (Markdown + HTML) auto-saves to ~/.yeaboi/exports/performance/<engineer>/ every run and via the Export button.
TUI: the coral Performance card → the beta notice → _run_performance_hub, the saved-artifacts landing (the shared _run_mode_hub, like standup/retro/poker/reporting) listing every engineer's preps, summaries, reviews and notes newest-first, each row naming who it is about. Open/Export/Share/Delete per row; + New artifact opens _build_performance_screen + _run_performance_page, which has three views, each with exactly one thing to move: a roster (↑/↓ choose an engineer, Enter opens them — key hints, no buttons), an actions view for that one engineer (←/→ across 1:1 Prep / 1:1 Complete / 6mo Review / Notes / History, the focused one described from _PERFORMANCE_ACTION_NOTES; History reopens the hub scoped to them; Esc returns to the roster), and a detail view showing the produced artifact (scroll + Export/Share/Anonymize; Esc returns to that engineer's actions). The action row only creates — Export and Share Online live on the artifact those produce, and both act on what is on screen (_shown_artifact()), not on the engineer's newest saved artifact. Generating paints the shared loading screen (_build_standup_progress_screen re-branded coral) with a declared phase checklist: PERF_PREP_PHASES / PERF_REVIEW_PHASES / PERF_COMPLETE_PHASES in _screens_secondary.py, keyed on the component ids evidence.py (one per SOURCE_*) and engine.py (PHASE_MODEL / PHASE_SAVE / PHASE_PRIOR / PHASE_EMAIL) emit through the shared analysis/progress.py contract. Each source's terminal status is derived from the SourceCoverage row that source just produced, so the live checklist and the artifact's coverage strip cannot disagree; a phase that cannot run still settles (no_data on the no-database path, failed on a broken store) because a row left pending reads as a hang. There is no cancel — Esc during a run is drained. Above six engineers or below thirty rows the roster becomes a compact one-line-each list (~9 visible instead of 2); a smaller team keeps the big ASCII rows. Rows come from PerformanceStore.get_all_history() / get_engineer_history(). Logs go to .
The detail view renders the artifact object, via ui/shared/_performance_rows.py:performance_detail_rows — which the CLI prints too, so there is one rendering rather than a page layout and a format_*_rich twin that hardcoded the mode accent. It lives under ui/shared/ rather than mode_select/screens/ so yeaboi perf does not import a ten-thousand-line module (374ms → 122ms). Numbers become tiles and meters, evidence becomes aligned rows, and prose stays prose; coverage is a counted legend plus a chip row under the headline, and the same four glyphs reappear beside every empty section. Rows are accumulated through ui/shared/_row_ctx.py (RowCtx + pack_viewport, shared with standup) so scroll offsets and terminal rows agree — a long list must be N one-row items, never a Rich Table, which as a single item taller than the viewport could never be shown. performance/render.py keeps only the plaintext builders the 1:1 summary email falls back to.
The export is React like every other surface. _perf_args carries stats, coverage, evidence and per-section ids alongside the bullet runs; Performance.tsx draws stat tiles (a ratio's bar inside its own tile), EvidenceList groups, and the shared Coverage.tsx dot. No stat carries a tone — Profile's Cell.tone earned its exception with per-column directional thresholds, and whether 62% of changes carrying tests is good is the reader's judgement. Section ids are slugged in Python only. Coverage sits at the foot of the page (the header already has a SOURCES eyebrow and the nav links down to it) where the TUI puts it at the top; that difference is deliberate and the component says so.
Reporting Mode
The src/yeaboi/reporting/ package produces a business-friendly summary of delivered work to relay back to stakeholders — over the last week, the last sprint, the last ~month (~2 sprints), a whole quarter, or a custom date range (PERIOD_WINDOW, explicit window_start/window_end). It follows the standup/retro/performance blueprint exactly: a self-contained package (engine + store + render/export + presentation + themes + pptx_export), a frozen-dataclass artifact in agent/state.py, a SQLite schema bumped in sessions.py, and a TUI page via the shared component system.
Design choice — standalone pipeline, not a LangGraph node. engine.run_delivery_report(period) is one deterministic gather step + a single get_llm()/track_usage() "design" call following the node parse → fallback → format convention. An LLM auth/billing error is never re-raised — it folds into warnings + a deterministic fallback report (counts + item list + generic emojis), so the page always renders. The engine takes on_progress (stage strings, threaded through the activity gather) and cancel_event (a threading.Event; a set event raises ReportCancelledError at the next stage boundary before anything persists) — the TUI runs it on a daemon worker thread behind the shared _build_standup_progress_screen loading screen, Esc cancels.
Data source — trackers. activity.gather_delivered_work(period) pulls team-wide recent activity via the same helpers the standup uses (jira_recent_activity / azdevops_recent_activity) + standup/sprint_context.gather for sprint dates, then keeps only tickets whose status means done (_COMPLETED_STATUSES: done/closed/resolved/released/completed/…). Look-back = 7 days for "last week", one sprint length for "last sprint", max(28, 2×sprint_weeks×7) days for "last month"; any explicit window_start (quarter sprint spans and the custom range alike) switches to days_override derived from that date. Graceful [] + a warning when no tracker is configured.
Source selection. The engine takes sources={"delivery": [...], "code": [...], "docs": [...]} (canonical tokens jira/azuredevops, github/azuredevops, confluence/notion — activity.normalize_sources accepts the azdevops/azure_devops/azdo alias spellings used by analysis/standup; a missing component key means auto-everything-configured, an explicitly empty list means none). delivery gates which tracker gather_delivered_work actually fetches (delivery_sources= blanks the unselected project id); activity.available_report_sources() probes what's configured. Surfaced as the TUI's analysis-style sources grid (first Generate, or the Sources button; sticky per page session), CLI --source jira|azdevops|both + --code-sources + --documentation-sources (assembled into the dict, CLI_HIDDEN/CLI_ONLY_DESTS entries in the parity registry), and the sources param on the MCP tool.
Supporting signals. context.gather_supporting_signals() (reporting/context.py) adds reference-only code/docs corroboration for the period: merged PRs + commits via standup.collector.collect_recent_activity(since=period_start) and recently-changed doc pages via analysis.doc_quality.collect_doc_pages — reduced to bounded SupportingSignal rows (kind × source, count, ≤5/3/6 sample "Title (ref)" strings, titles ≤120 chars, never bodies). Best-effort: a failing fetch becomes a report warning, never a crash; items past period_end are date-clamped. Signals attach to DeliveryReport.supporting_signals on both LLM and fallback paths (even 0-item runs), enter the prompt as a REFERENCE-ONLY block inside the untrusted context (with a "context, NOT deliverables" requirement — at most one corroborating clause in the executive summary), and render as a detail-view section, a ### Supporting signals Markdown section, one CLI line, and a context.signals_sentence() footnote on the HTML-deck + .pptx metrics slide.
Whole-quarter period. A third period reports on a calendar quarter (Q1 starts January). sprints.quarter_bounds(today) detects the current quarter (Q3 2026); sprints.list_sprints() returns real sprints with date ranges — live tracker first (new jira_list_sprints / azdevops_list_sprints helpers reusing the board/iteration discovery) then plan-derived dates (sprint_start_date + sprint_length_weeks×idx) as a fallback. The TUI shows a sprint multi-select (sprint_select view): ~12 sprints, the quarter's overlapping ones pre-checked (mark_in_quarter), Space toggles, Enter generates. The checked sprints' date span becomes the window: run_delivery_report(PERIOD_QUARTER, window_start=min start, window_end=min(max end, today), sprint_names=…, period_label_override="Q3 2026") ((custom) suffix when the selection differs from the detected set); gather_delivered_work(days_override=…) reports over that span. No tracker/plan sprints → the multi-select is skipped and it reports over the calendar-quarter dates. A truncation notice is added since the activity helpers cap at ~100 rows/source.
Hybrid presentation (the user's chosen approach): the LLM design call supplies the content — the executive narrative, the outcome themes, the highlights, and one emoji per section slot (parsed by engine._parse_themes / _parse_emoji, with a deterministic _DEFAULT_EMOJI fallback). presentation.deck_payload() arranges it into one JSON boot island — slides, every palette, and the style knobs — and build_presentation_html() wraps that around the committed deck bundle (frontend/src/deck, mounted by web/assets.render_page) as a self-contained offline document: Title → Executive summary → Metrics → per-Theme → Highlights → Thank-you, with keyboard nav (←/→/Space/Home/End), a progress rule, T to cycle palettes and F for fullscreen. Two things it no longer does, both deleted with the embedded-asset pattern: it does not generate CSS (custom palettes travel as data and are applied as custom properties), and it does not bake style colours against the opening palette (they resolve per palette client-side, so T now re-themes a chosen heading colour too). Pagination rides in a page: [i, n] pair feeding the slide's mono eyebrow rather than being suffixed onto the heading.
Themes — reporting/themes.py is the palette registry: 4 built-ins (midnight/aurora/sunset/mono, hexes mirroring the presentation CSS — a unit test pins them together) plus user-defined custom palettes from ~/.yeaboi/data/reporting_themes.json (paths.get_reporting_themes_path(); name → {bg1,bg2,fg,muted,accent,accent2} hex map, tolerant loader: bad JSON/hex/shadowed names skipped with a warning, missing roles filled from midnight). Custom palettes flow into the deck's injected [data-theme] CSS + T-cycle, the .pptx colors, the TUI swatch previews, --theme on the CLI, and the MCP tools.
PowerPoint — pptx_export.build_report_pptx() writes a native 16:9 .pptx deck (same slide order as the HTML deck, backgrounds/text colored from the selected palette). python-pptx is the optional docs extra: the import is lazy and a missing install returns None (the export dict simply has no pptx key; the TUI PowerPoint option shows an install hint).
Deck style — reporting/style.py holds the frozen DeckStyle (title/heading color overrides as palette-role-or-hex, font_family preset with a real pptx typeface + matching CSS stack, font_scale, layout detailed-vs-compact where compact groups the outcome themes as 2×2 cards on 1–2 slides, max_bullets caps with an "… and N more" marker via the shared cap_items, include_* section toggles, slide_numbers, footer_text). Persisted in ~/.yeaboi/data/reporting_prefs.json (paths.get_reporting_prefs_path(); tolerant load_deck_style/save_deck_style, style_from_dict clamps/defaults every bad value so untrusted MCP dicts are safe). Neutral-default invariant: DeckStyle() reproduces the historical output byte-for-byte — _style_css emits only deviations, pptx scaling is round(size*scale) — which is what keeps the pinned slide-count/CSS tests untouched. Builders never read disk; the persisted style is resolved at engine._export (so CLI + MCP report_delivery auto-exports honor it), the TUI passes its live state["style"], and MCP reporting_export merges a per-call style dict over the saved prefs. STYLE_FIELDS is the single ordered spec the Style screen + runner share.
Persistence & export: store.py defines _REPORTING_SCHEMA (schema v9 in sessions.py) with one reporting_history table. DeliveryReport/DeliveredItem are frozen dataclasses in agent/state.py (all fields defaulted; themes/metrics/emoji_theme are tuple-of-pairs so the whole artifact stays serializable). export.export_report() auto-saves Markdown + HTML + slide deck + .pptx (when installed) to ~/.yeaboi/exports/reporting/<project>/ every run and via the Export button (export_pptx_only() backs the picker's PowerPoint-only option); the HTML report reuses html_exporter._CSS, all tracker text html.escape-d.
TUI: the indigo Reporting card → _build_reporting_screen + _run_reporting_page in ui/mode_select/. Five views, styled with the analysis setup-wizard helpers (_analysis_setup_header/_analysis_toggle_row re-branded via their brand/theme params): a picker (↑/↓ choose Last week / Last sprint / Last month / Whole quarter / Custom range) with Generate Report / Sources / Theme / Style / Back and sources + style status lines; a sprint_select multi-select (↑/↓ move, Space toggle, Enter generate) shown when generating a quarter; a theme_select palette list with color-swatch previews (_build_reporting_theme_screen); a style_select deck-style options list (_build_reporting_style_screen: one row per STYLE_FIELDS entry, Space cycles the focused value in a working copy — colors cycle theme-default → roles → a custom-hex prompt via _standup_read_line, footer opens the line editor — Save / Reset / Back buttons: Save persists to reporting_prefs.json, Reset restores defaults in the editor, Back/Esc discards unsaved edits); and a detail view rendered richly from the DeliveryReport artifact (_reporting_detail_rows: headline banner, metric meters, titled sections, capped items table — one Text per terminal line so scroll math stays exact) with Export / Share Online / Anonymize / Theme / Style / Back. The custom range prompts start/end dates via _standup_read_line; anonymize masks the artifact via mask_artifact (a DeliveryReport reconstructor is registered in anonymize/apply.py). Scrollbars use build_scrollbar without always_show (a source-level regression test forbids it). Transient status messages (export paths etc.) render as a pinned one-row banner in the detail header — outside the scroll viewport, standup-style header_h accounting — so they stay visible at any scroll depth. Targets the most recent session for sprint length / project name. Logs go to ~/.yeaboi/logs/reporting/.
Agents family (agentwatch)
Four modes (agent-usage, agent-advisor, agent-standup, agent-security) share one package —
src/yeaboi/agentwatch/ — and one blueprint deviation worth knowing: the collector is the gather
step. collector.refresh() incrementally ingests ~/.claude/projects/**/*.jsonl (and OpenClaw)
into agent_sessions rollups, deduping usage by requestId via full-file reparse (Claude Code
splits one API response across lines with identical usage — partial offsets would double-count).
Three invariants:
- Privacy — no transcript text in the store, exports, or screens; security findings are
(pattern, file, line). Planted-secret tests enforce it.
- Deterministic numbers — every figure is computed in the engine; the LLM writes
insights/narrative/summary prose only.
- RO on
~/.claude — the fs_policy builtin rules are read-only; never write another tool's
state dir.
Cost goes through src/yeaboi/pricing.py (dated PRICING_AS_OF, cache-aware, unknown models →
flagged fallback tier). Exports are Markdown-only until an agentwatch React export component
exists (a tracked web-ux follow-up).
Advisor (advisor.py + waste_audit.py + cache_signals.py) is the fourth pipeline and the
one that lives outside engine.py. waste_audit.py and cache_signals.py
are vendored/adapted from Headroom (Apache-2.0; provenance in each module header and
THIRD_PARTY_NOTICES.md): the first re-reads the window's transcripts and sizes Read-waste
mechanisms (identical/subset re-reads, write read-backs, cat -n scaffolding, stale reads —
stale is sized but never summed into the recoverable headline), the second structurally detects
volatile-shaped content (UUID/ISO-8601/JWT-shape/hex-hash — counts only, no samples) in
prompt-prefix files (CLAUDE.md). Waste is priced at the window's input-token-weighted blended
rate via pricing.lookup_price. The TUI pages share one threaded-engine loop in
ui/mode_select/_agents.py; the landing split lives in screens/_screens_category.py and the
Agents card list is _AGENT_CARDS (never merged into _MODE_CARDS — welcome tests pin exact
renders and indices).
Roadmap Intake
The src/yeaboi/roadmap/ package makes Planning proactive: instead of describing a project by hand, the user points the 4th intake card (Roadmap, alongside Small/Large/Offline in _INTAKE_CARDS) at wherever their quarterly roadmap lives. One LLM call extracts the concrete candidate projects, ranks them by recommended start order, and classifies each as small (→ small_project intake) or large (→ smart intake); picking one launches run_session with the description pre-seeded from the roadmap.
Design choice — standalone pipeline, not a LangGraph node. engine.run_roadmap_analysis(source) is one deterministic ingest step + a single get_llm()/track_usage() call following the node parse → fallback → format convention (cloned from reporting/engine.py). An LLM auth/billing error is never re-raised — it folds into warnings + a deterministic zero-project fallback (no heuristic extraction: a wrong deterministic split would seed bad plans), so the page always renders and offers Re-analyze.
Sources (ingest.py): Confluence and Notion pages via new plain (non-@tool) helpers confluence_read_page_text / notion_read_page_text — they bypass the tools' 8k truncation with an explicit max_chars (Notion also recurses one level into has_children blocks, where roadmaps often live); and local files — .md/.txt/.rst (read_text), .pdf (reuses codebase._read_pdf, extra pdf), and NEW .docx/.pptx extractors (lazy python-docx/python-pptx, extra docs; pptx includes speaker notes). Locator parsers accept pasted URLs, bare ids, or (Confluence) page titles. Everything degrades to empty text + a warning — ingest_source never raises. Text is capped at _MAX_ROADMAP_CHARS = 24_000 with a truncation warning. The roadmap text is framed as UNTRUSTED DATA in the prompt (prompts/roadmap.py, ARC).
Persistence (store.py): _ROADMAP_SCHEMA (schema v10/v11 in sessions.py) with a multi-row roadmaps table — each row is one saved roadmap (source + latest analysis inline; Re-analyze updates in place), managed like planning projects (open / create / delete). Not session-keyed: a roadmap exists before any session. roadmap_history stays as the append-only run log; the v10 roadmap_config singleton is legacy, retained only so the v11 migration can seed the first roadmaps row from it. RoadmapProject/RoadmapAnalysis are frozen dataclasses in agent/state.py (all fields defaulted).
Pre-seed handoff: run_session(..., initial_description=...) threads through _run_session_body into _phase_description_input(initial_text=...) — the same prefill mechanism dry-run uses; the user lands in the normal editable Phase A editor and can adjust before submitting. intake_mode_for(project) maps size → intake mode.
TUI — saved roadmaps live in the Planning project list. There is no separate roadmap list: _load_planning_rows() in ui/mode_select/__init__.py merges planning projects (persistence.load_projects) with saved roadmaps (RoadmapStore.list_roadmaps) into one ProjectSummary list sorted by updated_at desc — roadmap rows carry kind="roadmap" + roadmap_id and render in "Your projects" as amber-[roadmap]-tagged cards (_build_project_card) whose meta line shows source · N candidate projects · analyzed <date> (or not analyzed yet); labels are humanized via store.friendly_label. The list's standard Delete button (confirm popup) branches on kind → delete_roadmap; the standard Export button on a roadmap row runs _export_roadmap_via_picker — the shared destination picker (_export_via_picker, mode="planning", no Jira/AzDO): Files → Markdown + HTML via roadmap/export.py, Notion/Confluence → publish_markdown(title="Roadmap — <label>", markdown=build_roadmap_markdown(...)). Enter on a roadmap card opens _run_roadmap_page(open_roadmap_id=…) straight into results (analyzing first if the row was never analyzed).
The page itself (_build_roadmap_screen in _screens_secondary.py + _run_roadmap_page, a Planning sub-page — PLANNING_THEME + planning_title) now has two views: a source picker (Confluence / Notion / Local file, with not-configured hints; locator entry reuses _standup_read_line re-branded with PLANNING_THEME) — this is home when creating a new roadmap from the Phase-4 Roadmap intake card; and a results view (summary line + ↑/↓-selectable project cards with [Small]/[Large] badges and quarter · themes meta; the selected card expands to reveal the project's full wrapped description + Why now: rationale while the others stay compact — _build_roadmap_project_card in _project_cards.py, _window_project_cards variable-height windowing + _build_peek_above/below stubs, no scrollbar; ⚠ Notices render as a distinct _build_roadmap_notices_card, degrading to a one-line hint on short terminals) with Plan This / Re-analyze / Change Source / Back. Return contract: (intake_mode, description) on Plan This, "done" on Back once a roadmap row exists (caller returns to the merged project list), None when backed out of the source view before saving (caller stays on the intake cards). The analysis runs on a worker thread while the frame loop animates a spinner + stage + elapsed time (run_roadmap_analysis(on_progress=…)); a busy flag renders a spinner-only screen (_build_roadmap_screen busy branch) so the source options underneath stay hidden during analysis. The analysis-profile picker is shared via the extracted _pick_analysis_profile(). --dry-run returns a canned 3-project analysis (no network/LLM). Logs go to ~/.yeaboi/logs/roadmap/.
Anonymize (post-processing action)
The src/yeaboi/anonymize/ package masks PII & company-specific data in a mode's already-generated output so real output can be shared publicly (README, website, posts). It is not a mode — an Anonymize button on every result screen (planning final sprint review, analysis, standup, retro, performance, reporting, roadmap), wired via one shared TUI helper.
Design — the engine produces a replacement map; the TUI masks each mode's own data in place. engine.run_anonymize(text) (fed a mode's get_document() → (title, markdown) Markdown) follows the standup/reporting parse → fallback → format convention: a deterministic seed pass literal-masks known company terms first (config — get_jira_project_key, AzDO org/project/team, Confluence space key, session project name, the ANONYMIZE_MASK_TERMS env list — longest-first, case-insensitive, keep_terms excluded), then one invoke_json LLM call generalizes to PII a static list can't know (names, other projects, internal tools). An LLM auth/billing/connection failure is never re-raised — it folds into warnings. Artifact AnonymizedOutput (frozen, agent/state.py): anonymized_text, replacements (the (original → placeholder) set — the TUI's masking driver), warnings. Prompt (prompts/anonymize.py, ARC) frames input as UNTRUSTED DATA.
In-place masking (anonymize/apply.py) — the redesign that keeps each mode's native card UI (never a separate raw-text view). apply_replacements(text, reps) generalizes the engine seed masker (boundary-aware, case-insensitive, longest-first) and is applied to each mode's own render shape: mask_artifact(artifact, reps) = asdict → deep-mask string leaves → the mode's _dict_to_* reconstructor (standup/retro/roadmap/analysis→TeamProfile._dict_to_profile); mask_lines(lines, reps) for the pre-rendered detail_lines/content_lines (performance/reporting/planning); mask_obj for side data (analysis examples).
TUI (no shared flow — each result loop holds an anon: AnonymizedOutput | None): Anonymize → the consistent loading screen (_run_anonymize_pass, reusing _build_standup_progress_screen with theme/title/label) runs run_anonymize on a worker thread → the loop re-renders its own screen from masked data with an anon_note subtitle ("Anonymized · N masked — review before sharing") and the button swaps to Adjust / Revert. Adjust re-runs with an appended free-text instruction (_standup_read_line, box_rows=6); Revert restores real data with no LLM call. Export/Copy while masked go through _anon_export, which applies the same replacements to the export Markdown (so file/clipboard match the screen) into ~/.yeaboi/exports/anonymize/<project>/. Roadmap (no Export button normally) gains one while masked.
Surface parity: the anonymize capability row = engine run_anonymize + MCP tool anonymize_text (mcp/tools_anonymize.py); TUI-card / CLI / skill columns Exempt (a button on existing screens, not a mode card).
Copy to clipboard
A zero-setup way to pull any page's data out of the terminal, alongside Export. clipboard.copy_text() (pbcopy / wl-copy / xclip; never raises) + clipboard.copy_markdown_status() (returns the "Copied to clipboard" / "Couldn't copy…" message) back two surfaces:
- A "Copy to clipboard" destination in the shared Export picker (
ui/shared/_export_picker.py: DEST_COPY, always available, 2nd after Files). Since every output mode routes through pick_export_destination, Copy appears everywhere Export does. The 4 dispatchers short-circuit on dest == "copy" by copying that flow's existing Markdown: _export_via_picker (standup/retro/performance/reporting/roadmap/anonymize-export), _team_profile_export_flow (analysis), _plan_export_flow (planning session), and the project-list export (saved projects). No new buttons on the crowded action rows.
- A standalone Copy button on the two picker-less utility pages — Usage (
usage_export.build_usage_text()) and Changelog (changelog.build_changelog_text()). Both screen builders (_build_usage_screen, _build_changelog_screen) take actions/message params; their page loops add left/right + a Copy dispatch.