Control-plane pivot: Phase 38-44 plan + bot scaffold

Direction shift 2026-04-22: docs/CONTROL_PLANE_PRD.md becomes the
long-horizon architecture target. Existing Lakehouse (docs/PRD.md,
Phases 0-37) is preserved as the reference implementation and first
consumer. New 6-layer architecture:

  L1 Universal API /v1/chat /v1/usage /v1/sessions /v1/tools /v1/context
  L2 Routing & Policy Engine (rules, fallback chains, cost gating)
  L3 Provider Adapter Layer (Ollama + OpenRouter + Gemini + Claude)
  L4 Knowledge + Memory + Playbooks (already built)
  L5 Execution Loop (scenarios + bot/cycle.ts instances)
  L6 Observability + token accounting

Phases 38-44 sequenced with detailed per-phase specs in the PRD.
Current scope: staffing domain (synthetic workers_500k, contracts,
emails, SMS, playbooks). DevOps (Terraform/Ansible) is long-horizon
target — architecture-compatible but not current.

Files added:
- docs/CONTROL_PLANE_PRD.md — 6-layer architecture, Phase 38-44
  sequencing with staffing-first Truth Layer + Validation pipeline
- bot/ — manual-only PR bot scaffold. First consumer test-bed for
  /v1/chat (Phase 38). Mem0-aligned ADD/UPDATE/NOOP apply semantics;
  KB feedback loop reads prior cycles on same gap and injects into
  cloud prompt so bot cycles compound like scenario.ts runs do.
- tests/multi-agent/run_stress.ts — the 6-task diverse stress test
  referenced in the previous commit but missing from its staging

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
profit
2026-04-22 02:43:31 -05:00
co-authored by Claude Opus 4.7
parent 5b1fcf6d27
commit f44b6b3e6b
13 changed files with 1872 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
# 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`.
+86
View File
@@ -0,0 +1,86 @@
// Apply a proposal to disk with Mem0-aligned ADD / UPDATE / NOOP
// semantics. Whole-file writes only — never deletes. All paths
// validated repo-relative + path-traversal-free.
//
// Three outcomes per file:
// ADD — file didn't exist, new content written (is_new must be true)
// UPDATE — file existed, content differs from current, overwritten
// NOOP — file existed, content identical to current, nothing written
//
// The NOOP case is important: a cloud proposal that "re-derives" an
// existing file shouldn't churn the repo or burn test cycles.
import { writeFile, readFile, mkdir, access } from "node:fs/promises";
import { dirname, resolve, relative } from "node:path";
import type { ApplyOutcome, Proposal } from "./types.ts";
const REPO_ROOT = "/home/profit/lakehouse";
export async function applyProposal(p: Proposal): Promise<ApplyOutcome> {
const added: string[] = [];
const updated: string[] = [];
const noop: string[] = [];
const errors: string[] = [];
for (const f of p.files) {
const err = validatePath(f.path);
if (err) {
errors.push(`${f.path}: ${err}`);
continue;
}
const abs = resolve(REPO_ROOT, f.path);
try {
const exists = await fileExists(abs);
if (f.is_new && exists) {
errors.push(`${f.path}: is_new=true but file exists`);
continue;
}
if (!f.is_new && !exists) {
// Proposal claims to update a file that doesn't exist.
// Refuse rather than silently ADD — model is confused about
// repo state, and we want that surfaced, not papered over.
errors.push(`${f.path}: is_new=false but file does not exist`);
continue;
}
if (!f.is_new) {
// UPDATE candidate — compare to detect NOOP.
const current = await readFile(abs, "utf8");
if (current === f.content) {
noop.push(f.path);
continue;
}
await writeFile(abs, f.content);
updated.push(f.path);
} else {
// ADD — file doesn't exist yet.
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, f.content);
added.push(f.path);
}
} catch (e) {
errors.push(`${f.path}: ${(e as Error).message}`);
}
}
return { added, updated, noop, errors };
}
function validatePath(p: string): string | null {
if (!p) return "empty path";
if (p.startsWith("/")) return "must be repo-relative";
if (p.startsWith("..") || p.includes("/../")) return "path traversal";
const abs = resolve(REPO_ROOT, p);
const rel = relative(REPO_ROOT, abs);
if (rel.startsWith("..")) return "resolves outside repo";
return null;
}
async function fileExists(abs: string): Promise<boolean> {
try {
await access(abs);
return true;
} catch {
return false;
}
}
+52
View File
@@ -0,0 +1,52 @@
// Daily cost tracker. Resets at UTC midnight by keying the file by date.
// Hard ceilings are the policy surface — the bot refuses to call the
// cloud once the budget is exhausted for the day.
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { CostState } from "./types.ts";
const COST_DIR = "/home/profit/lakehouse/data/_bot";
export const DAILY_CALLS_BUDGET = 20;
export const DAILY_TOKENS_BUDGET = 8000 * DAILY_CALLS_BUDGET; // 160k tokens/day ceiling
function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
function costFile(date: string): string {
return join(COST_DIR, `cost-${date}.json`);
}
export async function readCost(): Promise<CostState> {
const date = todayUtc();
try {
const raw = await readFile(costFile(date), "utf8");
return JSON.parse(raw) as CostState;
} catch {
return { date, calls: 0, tokens: 0 };
}
}
export async function recordCost(calls: number, tokens: number): Promise<CostState> {
await mkdir(COST_DIR, { recursive: true });
const current = await readCost();
const updated: CostState = {
date: current.date,
calls: current.calls + calls,
tokens: current.tokens + tokens,
};
await writeFile(costFile(updated.date), JSON.stringify(updated, null, 2));
return updated;
}
export function budgetCheck(c: CostState): { ok: boolean; reason: string } {
if (c.calls >= DAILY_CALLS_BUDGET) {
return { ok: false, reason: `daily call budget exhausted (${c.calls}/${DAILY_CALLS_BUDGET})` };
}
if (c.tokens >= DAILY_TOKENS_BUDGET) {
return { ok: false, reason: `daily token budget exhausted (${c.tokens}/${DAILY_TOKENS_BUDGET})` };
}
return { ok: true, reason: `${c.calls}/${DAILY_CALLS_BUDGET} calls, ${c.tokens}/${DAILY_TOKENS_BUDGET} tokens used today` };
}
+345
View File
@@ -0,0 +1,345 @@
// Single-cycle orchestrator. This is the manual entry point:
// bun run bot/cycle.ts
//
// Steps (each step can short-circuit with a clear outcome):
// 1. Pause-file check → skipped_pause
// 2. Cost budget check → skipped_cost
// 3. Gather CycleContext (dirty tree, last cycle, autotune status)
// 4. policy.shouldRunCycle → skipped_policy
// 5. findGaps + policy.pickGap → skipped_no_gap
// 6. generateProposal → model_failed
// 7. policy.scoreProposal → proposal_rejected
// 8. git checkout new branch
// 9. applyProposal → apply_failed
// 10. runTests → tests_failed
// 11. policy.shouldOpenPR → pr_skipped_by_policy
// 12. git commit + push
// 13. openPr → pr_failed / ok
// 14. recordCost + postEvent (every outcome)
// 15. write cycle result to data/_bot/cycles/{cycle_id}.json
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import type { CycleContext, CycleResult, CycleOutcome, Proposal } from "./types.ts";
import * as policy from "./policy.ts";
import { readCost, recordCost, budgetCheck, DAILY_CALLS_BUDGET, DAILY_TOKENS_BUDGET } from "./cost.ts";
import { findGaps, generateProposal } from "./propose.ts";
import { applyProposal } from "./apply.ts";
import { runTests } from "./test.ts";
import { openPr } from "./pr.ts";
import { postEvent } from "./observer.ts";
import { loadHistory, summarizeHistory, statsFor } from "./kb.ts";
const REPO_ROOT = "/home/profit/lakehouse";
const PAUSE_FILE = `${REPO_ROOT}/bot.paused`;
const CYCLES_DIR = `${REPO_ROOT}/data/_bot/cycles`;
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
function log(msg: string) { console.log(`[bot] ${msg}`); }
async function fileExists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
function git(args: string[]): { code: number; stdout: string; stderr: string } {
const r = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
}
async function gatherContext(cost: { calls: number; tokens: number }): Promise<CycleContext> {
// Last cycle: newest file in CYCLES_DIR.
let lastCycleAt: string | null = null;
let lastCycleGapId: string | null = null;
try {
const { readdir, stat } = await import("node:fs/promises");
const entries = await readdir(CYCLES_DIR).catch(() => [] as string[]);
let newestTs = 0;
let newestPath: string | null = null;
for (const e of entries) {
if (!e.endsWith(".json")) continue;
const p = join(CYCLES_DIR, e);
const s = await stat(p);
if (s.mtimeMs > newestTs) { newestTs = s.mtimeMs; newestPath = p; }
}
if (newestPath) {
const r = JSON.parse(await readFile(newestPath, "utf8")) as CycleResult;
lastCycleAt = r.started_at;
lastCycleGapId = r.gap?.id ?? null;
}
} catch {}
// Autotune status — if the agent is busy, skip this cycle to avoid
// contention on shared Ollama GPU memory.
let autotuneBusy = false;
try {
const r = await fetch(`${GATEWAY_URL}/vectors/agent/status`, { signal: AbortSignal.timeout(2000) });
if (r.ok) {
const j = await r.json() as any;
autotuneBusy = Boolean(j.running) && (j.queue_depth ?? 0) > 0;
}
} catch {}
// Dirty tree check.
const status = git(["status", "--porcelain"]);
const workingTreeDirty = status.code === 0 && status.stdout.trim().length > 0;
return {
startedAt: new Date().toISOString(),
dailyCallsUsed: cost.calls,
dailyCallsBudget: DAILY_CALLS_BUDGET,
dailyTokensUsed: cost.tokens,
dailyTokensBudget: DAILY_TOKENS_BUDGET,
lastCycleAt,
lastCycleGapId,
autotuneBusy,
workingTreeDirty,
};
}
async function persistResult(res: CycleResult): Promise<void> {
await mkdir(CYCLES_DIR, { recursive: true });
await writeFile(join(CYCLES_DIR, `${res.cycle_id}.json`), JSON.stringify(res, null, 2));
}
function sigHash(gap_id: string | null, proposalSummary: string): string {
const h = createHash("sha256");
h.update(gap_id ?? "no-gap");
h.update("|");
h.update(proposalSummary);
return h.digest("hex").slice(0, 16);
}
function emit(res: CycleResult): Promise<void> {
return postEvent({
source: "bot",
cycle_id: res.cycle_id,
sig_hash: sigHash(res.gap?.id ?? null, res.proposal?.summary ?? ""),
event_kind: res.outcome,
ok: res.outcome === "ok",
staffer_id: res.proposal?.model_used,
turns: res.cloud_calls,
duration_ms: new Date(res.ended_at).getTime() - new Date(res.started_at).getTime(),
extra: {
reason: res.reason,
pr_url: res.prUrl,
files_added: res.filesAdded,
files_updated: res.filesUpdated,
files_noop: res.filesNoop,
tests_green: res.testsGreen,
},
});
}
async function main() {
const cycle_id = `bot-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
const started_at = new Date().toISOString();
log(`cycle ${cycle_id} starting`);
const finish = async (outcome: CycleOutcome, reason: string, extra: Partial<CycleResult> = {}): Promise<CycleResult> => {
const res: CycleResult = {
cycle_id,
started_at,
ended_at: new Date().toISOString(),
outcome,
reason,
gap: null,
proposal: null,
filesAdded: [],
filesUpdated: [],
filesNoop: [],
testsGreen: null,
testsOutput: "",
prUrl: null,
tokens_used: 0,
cloud_calls: 0,
...extra,
};
await persistResult(res);
await emit(res);
log(`→ ${outcome}: ${reason}`);
return res;
};
// 1. Pause.
if (await fileExists(PAUSE_FILE)) {
return finish("skipped_pause", `pause file at ${PAUSE_FILE}`);
}
// 2. Cost.
const cost = await readCost();
const bc = budgetCheck(cost);
if (!bc.ok) return finish("skipped_cost", bc.reason);
log(`cost: ${bc.reason}`);
// 3+4. Context + policy.shouldRunCycle.
const ctx = await gatherContext(cost);
const srq = policy.shouldRunCycle(ctx);
if (!srq.run) {
return finish(ctx.workingTreeDirty ? "skipped_dirty_tree" : "skipped_policy", srq.reason);
}
// 5. Gaps + policy.pickGap.
const gaps = await findGaps();
log(`found ${gaps.length} [bot-eligible] gap(s) in PRD`);
const gapPick = policy.pickGap(gaps);
if (!gapPick.gap) return finish("skipped_no_gap", gapPick.reason);
const gap = gapPick.gap;
log(`picked gap ${gap.id}: "${gap.prd_line.slice(0, 80)}"`);
// 6. KB lookup + Proposal.
// Bot cycles compound: prior cycles on this gap inform the current
// proposal. Summary is a compact block injected into the cloud prompt
// so the model can build on past successes and steer around past
// failures. Empty string when this is the first cycle on this gap.
const history = await loadHistory(gap.id, 5);
const stats = statsFor(history);
if (history.length > 0) {
log(`kb: ${stats.attempts} prior cycle(s) — ${stats.pr_opened} pr / ${stats.tests_failed} tests_failed / ${stats.proposal_rejected} rejected / ${stats.noop} noop`);
} else {
log(`kb: first cycle on this gap`);
}
const historySummary = summarizeHistory(history);
let proposal: Proposal;
try {
proposal = await generateProposal(gap, historySummary);
log(`proposal: ${proposal.summary} (${proposal.files.length} files, ~${proposal.estimated_loc} LOC, ${proposal.tokens_used} tokens)`);
} catch (e) {
await recordCost(1, 0);
return finish("model_failed", (e as Error).message, { gap, cloud_calls: 1 });
}
await recordCost(1, proposal.tokens_used);
// 7. Score.
const score = policy.scoreProposal(proposal);
if (!score.accept) {
return finish("proposal_rejected", score.reason, { gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
}
log(`proposal accepted: ${score.reason}`);
// 8. Branch.
const dayStamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const branch = `bot/cycle-${dayStamp}-${gap.id}`;
// Branch must be fresh. If one already exists from a prior failed cycle
// for the same gap, bail — don't silently overwrite or reuse stale state.
const check = git(["rev-parse", "--verify", `refs/heads/${branch}`]);
if (check.code === 0) {
return finish("apply_failed",
`branch ${branch} already exists — delete it manually if you want a fresh cycle for this gap`,
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
}
const co = git(["checkout", "-b", branch]);
if (co.code !== 0) {
return finish("apply_failed", `git checkout -b failed: ${co.stderr}`,
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
}
log(`on branch ${branch}`);
// 9. Apply — Mem0 ADD/UPDATE/NOOP semantics.
const ap = await applyProposal(proposal);
if (ap.errors.length > 0) {
git(["checkout", "main"]);
git(["branch", "-D", branch]);
return finish("apply_failed", ap.errors.join("; "),
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
}
const changed = [...ap.added, ...ap.updated];
log(`apply: ${ap.added.length} add, ${ap.updated.length} update, ${ap.noop.length} noop`);
// Short-circuit: if every proposed file was identical to what's on
// disk, there's nothing to test or PR. Clean up the branch and exit.
if (changed.length === 0) {
git(["checkout", "main"]);
git(["branch", "-D", branch]);
return finish("cycle_noop",
`all ${proposal.files.length} proposed file(s) were identical to current state`,
{ gap, proposal, filesNoop: ap.noop,
tokens_used: proposal.tokens_used, cloud_calls: 1 });
}
// 10. Test.
log(`running tests (this will take a while)...`);
const testRes = await runTests();
if (!testRes.green) {
git(["checkout", "."]);
git(["checkout", "main"]);
git(["branch", "-D", branch]);
return finish("tests_failed", "cargo or bun test failed", {
gap, proposal,
filesAdded: ap.added, filesUpdated: ap.updated, filesNoop: ap.noop,
testsGreen: false, testsOutput: testRes.output,
tokens_used: proposal.tokens_used, cloud_calls: 1,
});
}
log(`tests green`);
// 11. PR gate.
const partial: CycleResult = {
cycle_id, started_at,
ended_at: new Date().toISOString(),
outcome: "ok", reason: "",
gap, proposal,
filesAdded: ap.added, filesUpdated: ap.updated, filesNoop: ap.noop,
testsGreen: true, testsOutput: testRes.output,
prUrl: null, tokens_used: proposal.tokens_used, cloud_calls: 1,
};
const prGate = policy.shouldOpenPR(partial);
if (!prGate.open) {
git(["checkout", "main"]);
git(["branch", "-D", branch]);
return finish("pr_skipped_by_policy", prGate.reason, partial);
}
// 12. Commit + push.
git(["add", ...changed]);
const commitMsg = [
`bot: ${proposal.summary}`,
"",
proposal.rationale,
"",
`PRD gap: ${gap.prd_line}`,
`Cycle: ${cycle_id}`,
"",
`Co-Authored-By: ${proposal.model_used} (cloud) <bot@lakehouse.local>`,
].join("\n");
const ci = git(["commit", "-m", commitMsg]);
if (ci.code !== 0) {
return finish("pr_failed", `git commit failed: ${ci.stderr}`, partial);
}
const ps = git(["push", "-u", "origin", branch]);
if (ps.code !== 0) {
return finish("pr_failed", `git push failed: ${ps.stderr}`, partial);
}
// 13. Open PR.
try {
const pr = await openPr({
branch,
title: `bot: ${proposal.summary}`,
body: [
`**Gap** (PRD line ${gap.line_number}): ${gap.prd_line}`,
"",
`**Rationale**`,
proposal.rationale,
"",
`**Cycle**: \`${cycle_id}\``,
`**Model**: \`${proposal.model_used}\``,
`**Tokens**: ${proposal.tokens_used}`,
`**Added**: ${ap.added.map(f => `\`${f}\``).join(", ") || "_none_"}`,
`**Updated**: ${ap.updated.map(f => `\`${f}\``).join(", ") || "_none_"}`,
ap.noop.length > 0 ? `**NOOP (identical to current)**: ${ap.noop.map(f => `\`${f}\``).join(", ")}` : "",
].join("\n"),
});
return finish("ok", `PR #${pr.number} opened`, { ...partial, prUrl: pr.html_url });
} catch (e) {
return finish("pr_failed", (e as Error).message, partial);
}
}
main().catch(e => {
console.error("[bot] fatal:", e);
process.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
// Bot-local knowledge base. Every finished cycle already persists a
// CycleResult to data/_bot/cycles/{id}.json — that IS the outcome log.
// KB here just reads that dir, filters to prior cycles on the same gap,
// and produces a short summary the cloud model can condition on.
//
// No separate jsonl, no new write path, no embedding calls. The bot's
// "memory" is the same primary artifact that the observer consumes.
//
// Future: embedding-based neighbor matching across gaps (cheap once
// sidecar is local), cross-pollination with scenario KB's
// pathway_recommendations. Not required for the feedback loop to work
// on a single gap — that's the floor we're building first.
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { CycleResult } from "./types.ts";
const CYCLES_DIR = "/home/profit/lakehouse/data/_bot/cycles";
export interface HistoryEntry {
cycle_id: string;
ended_at: string;
outcome: string;
reason: string;
pr_url: string | null;
tests_green: boolean | null;
files_added: string[];
files_updated: string[];
tokens_used: number;
}
export async function loadHistory(gap_id: string, max: number = 5): Promise<HistoryEntry[]> {
let entries: string[] = [];
try {
entries = await readdir(CYCLES_DIR);
} catch {
return [];
}
const matches: HistoryEntry[] = [];
for (const e of entries) {
if (!e.endsWith(".json")) continue;
try {
const raw = await readFile(join(CYCLES_DIR, e), "utf8");
const r = JSON.parse(raw) as CycleResult;
if (r.gap?.id !== gap_id) continue;
matches.push({
cycle_id: r.cycle_id,
ended_at: r.ended_at,
outcome: r.outcome,
reason: r.reason,
pr_url: r.prUrl,
tests_green: r.testsGreen,
files_added: r.filesAdded ?? [],
files_updated: r.filesUpdated ?? [],
tokens_used: r.tokens_used,
});
} catch {
// Skip unreadable / malformed cycle files. Don't fail the current
// cycle because an old one is corrupt.
}
}
matches.sort((a, b) => b.ended_at.localeCompare(a.ended_at));
return matches.slice(0, max);
}
// Compact prompt-ready summary. Empty string when there's no history —
// caller can skip the "prior attempts" block entirely.
export function summarizeHistory(h: HistoryEntry[]): string {
if (h.length === 0) return "";
const lines = h.map(e => {
const when = e.ended_at.slice(0, 16).replace("T", " ");
const files = [...e.files_added, ...e.files_updated];
const filesStr = files.length > 0 ? ` touched: ${files.join(", ")}` : "";
const prStr = e.pr_url ? ` PR: ${e.pr_url}` : "";
return `- ${when} UTC — ${e.outcome}${prStr}${filesStr}\n reason: ${e.reason}`;
});
return [
`Prior attempts on this gap (${h.length} most recent):`,
...lines,
"",
"Learn from these: build on what worked, avoid paths that failed.",
].join("\n");
}
// Aggregate stats for telemetry — lets the bot expose "% of cycles on
// this gap that landed a PR" without re-parsing the raw history.
export function statsFor(h: HistoryEntry[]): {
attempts: number;
pr_opened: number;
tests_failed: number;
proposal_rejected: number;
noop: number;
} {
return {
attempts: h.length,
pr_opened: h.filter(e => e.outcome === "ok").length,
tests_failed: h.filter(e => e.outcome === "tests_failed").length,
proposal_rejected: h.filter(e => e.outcome === "proposal_rejected").length,
noop: h.filter(e => e.outcome === "cycle_noop").length,
};
}
+34
View File
@@ -0,0 +1,34 @@
// POST bot cycle outcomes to the observer on :3800 so they accumulate
// in the KB alongside scenario + autotune events. Non-fatal on failure:
// the cycle result is also written to disk so observability isn't a
// single point of failure.
const OBSERVER_URL = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
export interface BotObserverEvent {
source: "bot";
cycle_id: string;
sig_hash: string; // stable hash of the gap + proposal for dedup
event_kind: string; // cycle outcome (ok, tests_failed, ...)
ok: boolean;
staffer_id?: string; // the model used, e.g. "gpt-oss:120b"
turns?: number; // number of cloud calls this cycle
duration_ms?: number;
extra?: Record<string, any>;
}
export async function postEvent(ev: BotObserverEvent): Promise<void> {
try {
const r = await fetch(`${OBSERVER_URL}/event`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ev),
signal: AbortSignal.timeout(3000),
});
if (!r.ok) {
console.error(`[bot/observer] ${r.status}: ${await r.text()}`);
}
} catch (e) {
console.error(`[bot/observer] POST failed: ${(e as Error).message}`);
}
}
+70
View File
@@ -0,0 +1,70 @@
// ═══════════════════════════════════════════════════════════════════
// YOU WRITE THIS FILE. These four functions shape the bot's entire
// behavior. Defaults below are safe and intentionally conservative —
// redline to taste. Each function is ~5-10 lines.
// ═══════════════════════════════════════════════════════════════════
import type { CycleContext, CycleResult, Gap, Proposal } from "./types.ts";
// Should this cycle even fire right now?
// Called AFTER the pause-file check + cost-cap check have already passed.
// Use this for judgment calls (cooldown, shared-resource conflicts).
export function shouldRunCycle(ctx: CycleContext): { run: boolean; reason: string } {
if (ctx.workingTreeDirty) {
return { run: false, reason: "dirty working tree — commit or stash your work first" };
}
if (ctx.lastCycleAt) {
const minsAgo = (Date.now() - new Date(ctx.lastCycleAt).getTime()) / 60000;
if (minsAgo < 10) return { run: false, reason: `last cycle ${minsAgo.toFixed(1)}min ago (<10min cooldown)` };
}
if (ctx.autotuneBusy) {
return { run: false, reason: "autotune agent is running — avoid Ollama contention" };
}
return { run: true, reason: "ready" };
}
// Which [bot-eligible] gap from the PRD does this cycle attack?
// Default: first in document order. Alternatives: random for exploration,
// smallest-context, tag-priority (e.g. [bot-eligible:high] wins).
export function pickGap(gaps: Gap[]): { gap: Gap | null; reason: string } {
if (gaps.length === 0) return { gap: null, reason: "no [bot-eligible] gaps in PRD" };
return { gap: gaps[0], reason: "first eligible gap in document order" };
}
// Cheap gate BEFORE burning test cycles. Reject if proposal smells wrong.
// This catches runaway cloud proposals before we apply+test them.
export function scoreProposal(p: Proposal): { accept: boolean; reason: string } {
if (p.files.length === 0) return { accept: false, reason: "empty proposal" };
if (p.files.length > 5) return { accept: false, reason: `>5 files (${p.files.length})` };
if (p.estimated_loc > 200) return { accept: false, reason: `>200 LOC (${p.estimated_loc})` };
// Paths the bot is NEVER allowed to touch. Extend cautiously.
const forbidden = [
".git/", "secrets", "lakehouse.toml",
"docs/ADR-", "docs/DECISIONS.md", "docs/PRD.md",
"/etc/", "/root/", "Cargo.lock",
];
for (const f of p.files) {
const hit = forbidden.find(prefix => f.path.includes(prefix));
if (hit) return { accept: false, reason: `touches forbidden path (${hit}): ${f.path}` };
}
return { accept: true, reason: "passes size + path filters" };
}
// Tests are green and changes applied. Do we actually open a PR?
// Mem0-aligned: the cycle already short-circuited with `cycle_noop` if
// every file was identical to current state, so by the time we're here
// at least one file was actually added or updated.
//
// Stricter options below — uncomment to require a test file touched, etc.
export function shouldOpenPR(c: CycleResult): { open: boolean; reason: string } {
if (!c.testsGreen) return { open: false, reason: "tests red" };
const realChanges = c.filesAdded.length + c.filesUpdated.length;
if (realChanges === 0) return { open: false, reason: "nothing actually changed on disk" };
// Stricter option — require at least one test file in the diff:
// const hasTest = [...c.filesAdded, ...c.filesUpdated].some(f => /test|_tests/i.test(f));
// if (!hasTest) return { open: false, reason: "no test file touched" };
return { open: true, reason: `green + ${c.filesAdded.length} add / ${c.filesUpdated.length} update` };
}
+58
View File
@@ -0,0 +1,58 @@
// Gitea PR open. Reads PAT from ~/.git-credentials (set up by the
// credential-helper flow). All PRs open as DRAFT so J reviews before
// merge — never auto-merge.
import { readFile } from "node:fs/promises";
const GITEA_HOST = "https://git.agentview.dev";
const REPO_OWNER = "profit";
const REPO_NAME = "lakehouse";
const CRED_FILE = "/home/profit/.git-credentials";
async function readPat(): Promise<string> {
const raw = await readFile(CRED_FILE, "utf8");
for (const line of raw.split("\n")) {
const m = line.match(/^https:\/\/[^:]+:([^@]+)@git\.agentview\.dev/);
if (m) return m[1];
}
throw new Error(`no Gitea PAT found in ${CRED_FILE}`);
}
export interface OpenPrInput {
branch: string; // head branch (just pushed)
base?: string; // default "main"
title: string;
body: string;
}
export interface OpenPrResult {
number: number;
html_url: string;
}
export async function openPr(input: OpenPrInput): Promise<OpenPrResult> {
const pat = await readPat();
const url = `${GITEA_HOST}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls`;
const payload = {
title: input.title,
body: input.body,
head: input.branch,
base: input.base ?? "main",
// Gitea uses "draft" flag — always on for bot PRs.
draft: true,
};
const r = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"Authorization": `token ${pat}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30000),
});
if (!r.ok) {
throw new Error(`Gitea ${r.status}: ${await r.text()}`);
}
const j = await r.json() as any;
return { number: j.number, html_url: j.html_url };
}
+141
View File
@@ -0,0 +1,141 @@
// Gap detection + cloud proposal.
//
// Gap detection: scan docs/PRD.md for lines tagged [bot-eligible].
// Each match becomes a Gap with surrounding context.
//
// Proposal: one-shot call to the T3 cloud model via the Python sidecar's
// /generate endpoint. Asks for a structured JSON response with file
// contents. Truncation-resistant via Phase 21's generate_continuable —
// for now we pass max_tokens high and rely on the model completing in
// one pass; swap to the Rust continuation wrapper if we see truncation.
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import type { Gap, Proposal } from "./types.ts";
const SIDECAR_URL = process.env.LH_SIDECAR_URL ?? "http://localhost:3200";
const REPO_ROOT = "/home/profit/lakehouse";
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
const MAX_TOKENS = 6000;
export async function findGaps(): Promise<Gap[]> {
const prd = await readFile(PRD_PATH, "utf8");
const lines = prd.split("\n");
const gaps: Gap[] = [];
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes("[bot-eligible]")) continue;
const contextLines = lines.slice(i, Math.min(i + 6, lines.length)).join("\n");
const id = createHash("sha256").update(lines[i]).digest("hex").slice(0, 12);
gaps.push({
id,
prd_line: lines[i].trim(),
context: contextLines,
source_file: "docs/PRD.md",
line_number: i + 1,
});
}
return gaps;
}
const SYSTEM_PROMPT = `You are an assistant that proposes small, testable code changes to the Lakehouse repo.
The Lakehouse is a Rust-first data platform with 13 crates + Bun/TypeScript test harness.
You will be given one PRD gap tagged [bot-eligible] and must respond with a STRICT JSON object — no prose.
Rules:
- Response MUST be a single JSON object, no markdown fences, no commentary.
- Change MUST be small: <200 lines total, ≤5 files.
- Include at least one test file (new or modified) that proves the change.
- NEVER touch .git/, secrets, lakehouse.toml, docs/ADR-*, docs/DECISIONS.md, docs/PRD.md, /etc/, /root/, Cargo.lock.
- Paths MUST be repo-relative (no leading /).
- Whole-file contents only — no patches, no diffs.
Response shape:
{
"summary": "one line",
"rationale": "why this addresses the gap",
"files": [ { "path": "crates/foo/src/bar.rs", "content": "<full file>", "is_new": false } ],
"estimated_loc": 42
}`;
export async function generateProposal(gap: Gap, historySummary: string = ""): Promise<Proposal> {
const sections = [
`PRD gap (line ${gap.line_number}):`,
"```",
gap.context,
"```",
"",
];
if (historySummary) {
sections.push(historySummary, "");
}
sections.push("Propose a small change that addresses this gap. Respond with the JSON object only.");
const userPrompt = sections.join("\n");
const r = await fetch(`${SIDECAR_URL}/generate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: CLOUD_MODEL,
system: SYSTEM_PROMPT,
prompt: userPrompt,
temperature: 0.2,
max_tokens: MAX_TOKENS,
think: false,
}),
signal: AbortSignal.timeout(180000), // cloud T3 can be slow — 3 min
});
if (!r.ok) {
throw new Error(`sidecar ${r.status}: ${await r.text()}`);
}
const j = await r.json() as any;
const raw: string = j.text ?? j.response ?? "";
const usage = j.usage ?? {};
const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
const parsed = extractJson(raw);
if (!parsed || typeof parsed !== "object") {
throw new Error(`model returned no JSON object. Raw head: ${raw.slice(0, 300)}`);
}
if (!Array.isArray(parsed.files)) {
throw new Error(`proposal.files not an array: ${JSON.stringify(parsed).slice(0, 200)}`);
}
return {
summary: String(parsed.summary ?? "").trim(),
rationale: String(parsed.rationale ?? "").trim(),
files: parsed.files.map((f: any) => ({
path: String(f.path ?? ""),
content: String(f.content ?? ""),
is_new: Boolean(f.is_new),
})).filter((f: any) => f.path && f.content !== undefined),
estimated_loc: Number(parsed.estimated_loc ?? 0),
model_used: CLOUD_MODEL,
tokens_used: tokens,
};
}
// Find the first balanced JSON object in the string. Tolerates leading
// "```json" fences even though we asked the model not to emit them.
function extractJson(text: string): any | null {
const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
let depth = 0;
let start = -1;
for (let i = 0; i < cleaned.length; i++) {
const c = cleaned[i];
if (c === "{") {
if (depth === 0) start = i;
depth++;
} else if (c === "}") {
depth--;
if (depth === 0 && start >= 0) {
try {
return JSON.parse(cleaned.slice(start, i + 1));
} catch {
start = -1;
}
}
}
}
return null;
}
+46
View File
@@ -0,0 +1,46 @@
// Run the test gate: `cargo test --workspace` + `bun test`.
// Returns { green, output } — output is last ~4KB of combined stdout/stderr.
import { spawn } from "node:child_process";
const REPO_ROOT = "/home/profit/lakehouse";
const TEST_TIMEOUT_MS = 15 * 60 * 1000; // 15 min cargo + 2 min bun, generous
export async function runTests(): Promise<{ green: boolean; output: string }> {
const cargoOut = await runCmd("cargo", ["test", "--workspace", "--quiet", "--", "--test-threads=1"], REPO_ROOT, TEST_TIMEOUT_MS);
if (cargoOut.code !== 0) {
return { green: false, output: tail(`cargo test (${cargoOut.code}):\n${cargoOut.combined}`) };
}
const bunOut = await runCmd("bun", ["test", "tests/multi-agent"], REPO_ROOT, 120000);
const bunGreen = bunOut.code === 0;
const combined = [
`cargo test: OK`,
`bun test (${bunOut.code}):`,
bunOut.combined,
].join("\n");
return { green: bunGreen, output: tail(combined) };
}
function tail(s: string, n = 4096): string {
return s.length > n ? "…" + s.slice(-n) : s;
}
function runCmd(
cmd: string,
args: string[],
cwd: string,
timeoutMs: number,
): Promise<{ code: number; combined: string }> {
return new Promise(resolveP => {
const child = spawn(cmd, args, { cwd, env: { ...process.env } });
let combined = "";
child.stdout.on("data", d => { combined += d.toString(); });
child.stderr.on("data", d => { combined += d.toString(); });
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.on("close", code => {
clearTimeout(timer);
resolveP({ code: code ?? -1, combined });
});
});
}
+88
View File
@@ -0,0 +1,88 @@
// Shared types for the PR-bot. Small and explicit — the bot's behavior
// should be readable from these shapes alone.
export interface Gap {
id: string; // hash of prd_line — stable key for dedup
prd_line: string; // the [bot-eligible]-tagged line, verbatim
context: string; // surrounding context from the PRD (next ~5 lines)
source_file: string; // e.g. "docs/PRD.md"
line_number: number;
}
export interface ProposalFile {
path: string; // repo-relative (no leading slash)
content: string; // full file content — the bot writes whole files, not patches
is_new: boolean; // true = create, false = overwrite existing
}
export interface Proposal {
summary: string; // one-line
rationale: string; // why this change addresses the gap
files: ProposalFile[];
estimated_loc: number; // total added+changed lines across all files
model_used: string;
tokens_used: number;
}
export interface CycleContext {
startedAt: string; // ISO
dailyCallsUsed: number;
dailyCallsBudget: number;
dailyTokensUsed: number;
dailyTokensBudget: number;
lastCycleAt: string | null;
lastCycleGapId: string | null;
autotuneBusy: boolean;
workingTreeDirty: boolean;
}
export type CycleOutcome =
| "ok" // PR opened
| "skipped_pause"
| "skipped_cost"
| "skipped_policy"
| "skipped_no_gap"
| "skipped_dirty_tree"
| "proposal_rejected"
| "apply_failed"
| "cycle_noop" // proposal applied but every file was identical to what's on disk
| "tests_failed"
| "pr_skipped_by_policy"
| "pr_failed"
| "model_failed";
// Mem0-aligned apply outcomes. Three shapes instead of the binary
// "written" / "errored". NOOP is a first-class outcome — identical
// content shouldn't waste test cycles or open an empty PR.
export type ApplyMode = "add" | "update" | "noop";
export interface ApplyOutcome {
added: string[];
updated: string[];
noop: string[];
errors: string[];
}
export interface CycleResult {
cycle_id: string;
started_at: string;
ended_at: string;
outcome: CycleOutcome;
reason: string;
gap: Gap | null;
proposal: Proposal | null;
filesAdded: string[];
filesUpdated: string[];
filesNoop: string[];
testsGreen: boolean | null; // null = not run
testsOutput: string;
prUrl: string | null;
tokens_used: number;
cloud_calls: number;
}
export interface CostState {
date: string; // YYYY-MM-DD UTC
calls: number;
tokens: number;
}