# Lakehouse PR-Bot A local sub-agent that reads the PRD, asks a cloud model for a small change proposal, applies it, runs tests, and opens a draft PR on Gitea. Manual-only right now — no systemd, no cron. Run one cycle at a time and watch. ## Run one cycle ```bash cd /home/profit/lakehouse bun run bot/cycle.ts ``` Prerequisites: - Working tree must be clean (`git status` shows nothing). The bot refuses on a dirty tree so its changes don't mix with in-flight work. - Sidecar running on :3200, gateway on :3100, observer on :3800. - At least one PRD line tagged `[bot-eligible]` (see below). - Gitea PAT configured in `~/.git-credentials` (already set up). ## Tag a gap as bot-eligible Add `[bot-eligible]` to any PRD line the bot is allowed to work on: ```md - [ ] Add a unit test for parse_city_state covering "South Bend, IN" edge case [bot-eligible] ``` The bot scans `docs/PRD.md` for these tags. Each tagged line becomes a candidate. Start small — one tag at a time — until you trust the loop. ## Stop it mid-cycle Create the pause file: ```bash touch /home/profit/lakehouse/bot.paused ``` The next `bot/cycle.ts` invocation exits immediately with `skipped_pause`. (It does NOT kill a cycle already in-flight — use `Ctrl-C` or `pkill -f bot/cycle.ts` for that.) ## Budget - **20 cloud calls/day, 160k tokens/day** (hard ceiling, see `bot/cost.ts`). - Tracked in `data/_bot/cost-YYYY-MM-DD.json`. - Resets at UTC midnight. ## Cycle outcomes Every run writes a result to `data/_bot/cycles/{cycle_id}.json` and POSTs an event to the observer on :3800. Possible outcomes: | Outcome | Meaning | |---|---| | `ok` | PR opened | | `cycle_noop` | proposal applied, every file was **identical to current content** (Mem0 NOOP); no test run, no PR | | `skipped_pause` | pause file present | | `skipped_cost` | daily budget exhausted | | `skipped_policy` | `policy.shouldRunCycle` said no | | `skipped_dirty_tree` | uncommitted changes present | | `skipped_no_gap` | no `[bot-eligible]` tags in PRD | | `model_failed` | sidecar/cloud model errored or returned unparseable JSON | | `proposal_rejected` | `policy.scoreProposal` rejected it (size, path, etc.) | | `apply_failed` | file write errored | | `tests_failed` | cargo or bun test red | | `pr_skipped_by_policy` | green tests, but `policy.shouldOpenPR` said no | | `pr_failed` | Gitea API or git push errored | ## Mem0-aligned apply semantics `apply.ts` categorizes every proposed file into one of three modes: | Mode | Trigger | Action | |---|---|---| | **ADD** | `is_new: true` and file doesn't exist | Create the file | | **UPDATE** | `is_new: false`, file exists, content **differs** from current | Overwrite | | **NOOP** | `is_new: false`, file exists, content **matches** current exactly | Skip (no write, no diff) | If every file in the proposal is NOOP, the cycle short-circuits to `cycle_noop` **before** running tests or opening a PR. Mismatched shapes (`is_new:true` but file exists, or `is_new:false` but file missing) become `apply_failed` — model state confusion is surfaced, not papered over. PR bodies report the three counts separately so reviewers see what actually changed vs. what was confirmed identical. ## How the bot compounds over cycles Every finished cycle persists a `CycleResult` to `data/_bot/cycles/{cycle_id}.json`. At the **start** of the next cycle, `bot/kb.ts::loadHistory(gap_id)` scans that directory, filters to prior cycles on the same gap, and returns the five most recent outcomes (`ok`, `tests_failed`, `proposal_rejected`, `cycle_noop`, etc.) with their reasons and touched files. Those outcomes are summarized into a compact block and **injected into the cloud prompt** before asking for a new proposal. The model sees things like: ``` Prior attempts on this gap (3 most recent): - 2026-04-22 03:15 UTC — tests_failed reason: cargo test -p vectord::lance failed on field_type_coerce - 2026-04-22 02:48 UTC — proposal_rejected reason: touched forbidden path (docs/ADR-): docs/ADR-019-update.md - 2026-04-22 01:23 UTC — ok PR: https://git.agentview.dev/profit/lakehouse/pulls/142 reason: PR #142 opened Learn from these: build on what worked, avoid paths that failed. ``` This is the same compounding pattern `scenario.ts` uses via `kb.loadRecommendation` — the bot's cycles are the bot's memory. No embedding, no separate jsonl, no cross-run orchestration required. First cycle on a new gap skips the block cleanly. The observer events POSTed on every cycle carry the same data, so `GET :3800/stats?source=bot` aggregates "% of bot cycles on similar gaps that landed a PR" without extra plumbing. ## Where YOU edit `bot/policy.ts` — four small functions that define what the bot does. The rest (`propose.ts`, `apply.ts`, `test.ts`, `pr.ts`, `cycle.ts`, `kb.ts`) is mechanical orchestration — you shouldn't need to touch it unless a pipeline changes. One policy upgrade the history opens up: in `shouldRunCycle`, you can now bail out if the same gap has failed N times in a row. Example addition: ```ts import { loadHistory, statsFor } from "./kb.ts"; // inside shouldRunCycle — but you'd need the gap, which is picked later. // A more natural place is scoreProposal: if prior N failures on this // gap's path+summary pattern, reject before testing. ``` ## Hard-coded guardrails (not in policy — can't be disabled) - Bot **never deletes files** (`apply.ts` has no delete path). - Bot **never touches** `.git/`, `secrets`, `lakehouse.toml`, `docs/ADR-*`, `docs/DECISIONS.md`, `docs/PRD.md`, `/etc/`, `/root/`, `Cargo.lock`. - All paths validated for traversal (`path/../`) and repo-escape. - PRs always open as **draft** — never auto-merge. - Budget check is before the policy function runs — no way to override the daily cap from `policy.ts`.