Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e0ea70f
fix: cred detection + Claude MCP user-scope registration
crypticpy May 8, 2026
2a2cde2
fix: reviewer fidelity, verdict surfacing, event/prompt isolation
crypticpy May 8, 2026
6713be0
fix: replay chat_done from persisted verdict, not status
crypticpy May 8, 2026
9fe372c
fix: surface dropped attached_files + SSE backpressure; harden ship.ts
crypticpy May 8, 2026
29a6138
fix: bound failure-summary regex; log malformed SSE frames
crypticpy May 8, 2026
f290fae
feat: github PR ingestion via gh CLI
crypticpy May 8, 2026
6420e00
refactor: extract createChatFromValidatedInputs helper
crypticpy May 8, 2026
fdc1c7d
feat: POST /chats/from-pr — start a chat from a GitHub PR URL
crypticpy May 8, 2026
420fa63
feat: cockpit "GitHub PR" tab on /new
crypticpy May 8, 2026
caaab68
feat: review_pr MCP tool
crypticpy May 8, 2026
046eeb5
docs: capture multi-identity CLI follow-up idea
crypticpy May 8, 2026
e74eebd
feat: schema for audit + orchestrate phases, voice tier, bypass_quota
crypticpy May 8, 2026
109afce
feat: structured-output adapter for CLI voices
crypticpy May 8, 2026
75112af
feat(cockpit): audit-a-repo tab + checklist approval component
crypticpy May 8, 2026
a3704a1
feat: PR-review chats bypass quota + tier surface on /voices
crypticpy May 8, 2026
631dc97
feat: audit phase + 5 presets + audit-* templates
crypticpy May 8, 2026
d88c17f
feat: orchestrate phase + audit-resume wiring + tier-aware scheduler
crypticpy May 8, 2026
41ab8c6
feat: orchestrate manifest UI + checkout/open-pr daemon routes
crypticpy May 8, 2026
e93ce00
fix: harden resume race + branch validation + symlink TOCTOU + extrac…
crypticpy May 8, 2026
dec11ae
fix: prod CJS build — drop import.meta + copy presets to dist
crypticpy May 8, 2026
535a960
feat: fold upstream T1+T2 fixes back into fork (12 commits) (#2)
crypticpy May 17, 2026
7c11444
feat: fold upstream Grok + Local LLM + Keychain dual-probe (4 commits…
crypticpy May 17, 2026
7828b3a
feat: fold upstream contributor stack — repoPath default + CRLF perso…
crypticpy May 17, 2026
c3d4b13
docs: pr-babysit design sketch (judge workflow + state machine)
crypticpy May 17, 2026
d09e6c6
feat: prime doer/reviewer prompts with AGENTS.md + CLAUDE.md
crypticpy May 17, 2026
590339c
feat: verify phase — exec package.json chorus.verify, judge with revi…
crypticpy May 17, 2026
1d177b2
feat: TDD loop — verify failure re-prompts named feedback phase doer
crypticpy May 17, 2026
b9776e4
feat: babysit DB — jobs + decisions tables, query helpers, 25 tests
crypticpy May 17, 2026
7f26005
feat: babysit comment fetcher — gh CLI pull + author classify + sha25…
crypticpy May 17, 2026
8d52b97
feat: babysit judge — classify PR-bot comments + pure action router
crypticpy May 17, 2026
061a58a
feat: babysit MCP tool + daemon registrar + pr-babysit preset
crypticpy May 18, 2026
2bdb1c6
feat: babysit GH App auth — RS256 JWT + installation token cache
crypticpy May 18, 2026
4787926
feat: babysit webhook HMAC verify helper
crypticpy May 18, 2026
6e26e1d
feat: babysit GH client — App-auth + CLI-fallback request shim
crypticpy May 18, 2026
e63a0d1
feat: babysit per-PR worktree manager
crypticpy May 18, 2026
4298dd0
feat: babysit scheduler — bounded concurrency + per-job mutex
crypticpy May 18, 2026
67dc2bc
feat: babysit state machine — full judge→fix→verify→push→quiet loop
crypticpy May 18, 2026
503552b
feat: wire babysit scheduler into daemon lifecycle
crypticpy May 18, 2026
be48d86
feat: babysit pause/resume route — PATCH /babysit/jobs/:id
crypticpy May 18, 2026
8a51c8c
feat: chorus babysit CLI — list/show/register/pause/resume
crypticpy May 18, 2026
99fa9c2
chore: merge origin/main into feat/runner-execution-loop — absorb PR …
crypticpy May 18, 2026
dd80178
fix(babysit): App-aware comment fetch + CLI body POST via stdin
crypticpy May 18, 2026
c72bfa5
fix(babysit): tick-2 bot-review batch — verify gating, idempotent ret…
crypticpy May 18, 2026
5b62a94
fix(babysit): tick-3 — fail-closed comment fetch, verify abort short-…
crypticpy May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions docs/pr-babysit-design.md

Large diffs are not rendered by default.

333 changes: 333 additions & 0 deletions src/cli/commands/babysit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
/**
* `chorus babysit` — inspect + control the PR-babysit scheduler from the CLI.
*
* chorus babysit register <pr-url> [--installation-id <n>]
* chorus babysit list [--active] [--state <state>]
* chorus babysit show <id>
* chorus babysit pause <id>
* chorus babysit resume <id>
*
* All commands talk to the local daemon over its REST API. The job id is
* "<owner>/<repo>#<number>" — pass it quoted in shells that treat # as a
* comment.
*/
import type { Command } from "commander";
import { resolveDaemonUrl } from "../../lib/daemon-discovery.js";
import { c, header, kv, sym } from "../ui.js";

interface JobRow {
id: string;
repo: string;
pr_number: number;
state: string;
updated_at: number;
started_at: number;
ended_at: number | null;
fix_commits: number;
total_judge_calls: number;
total_fix_calls: number;
escalation_reason: string | null;
installation_id: number | null;
worktree_path: string | null;
}

interface DecisionRow {
id: number;
decided_at: number;
comment_id: number;
comment_author: string;
bot: string | null;
validity: string;
category: string;
confidence: number;
outcome: string | null;
}

interface ApiOk<T> {
ok: true;
data: T;
}
interface ApiErr {
ok: false;
error: { code: string; message: string };
}
type ApiResult<T> = ApiOk<T> | ApiErr;

async function callDaemon<T>(
path: string,
init?: { method?: string; body?: unknown },
): Promise<ApiResult<T>> {
const daemonUrl = await resolveDaemonUrl();
let response: Response;
try {
response = await fetch(`${daemonUrl}/api/v1${path}`, {
method: init?.method ?? "GET",
headers: init?.body ? { "content-type": "application/json" } : undefined,
body: init?.body ? JSON.stringify(init.body) : undefined,
});
} catch {
return {
ok: false,
error: {
code: "connection_failed",
message: "Daemon is not running. Start with `chorus start`.",
},
};
}
// The envelope itself carries ok/error so we trust the body shape over
// HTTP status — but a non-JSON body (e.g. fastify 404 HTML) would throw.
let body: unknown;
try {
body = await response.json();
} catch {
return {
ok: false,
error: {
code: "parse_error",
message: `Daemon returned non-JSON response (HTTP ${response.status})`,
},
};
}
// Validate the envelope shape before handing it to callers — a
// mis-routed proxy/CDN can return arbitrary JSON without `ok`, and
// downstream code (e.g. the `chorus babysit register` formatter)
// dereferences `res.error` which would crash on a bare {} body.
if (
typeof body === "object" &&
body !== null &&
"ok" in body &&
typeof (body as { ok?: unknown }).ok === "boolean"
) {
return body as ApiResult<T>;
}
return {
ok: false,
error: {
code: "invalid_envelope",
message: `Daemon returned unexpected JSON shape (HTTP ${response.status})`,
},
};
}

function relTime(ms: number | null): string {
if (ms === null) return "—";
const delta = Date.now() - ms;
if (delta < 0) return "just now";
const s = Math.floor(delta / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}

function stateColor(state: string): string {
switch (state) {
case "idle":
return c.dim(state);
case "merged":
return c.green(state);
case "escalated":
return c.red(state);
case "paused":
return c.yellow(state);
case "judging":
case "fixing":
case "verifying":
case "pushing":
case "quiet_check":
case "waiting":
return c.cyan(state);
default:
return state;
}
}

function dieWithError(err: { code: string; message: string }): never {
console.log("");
console.log(header(sym.err, err.message, err.code));
console.log("");
process.exit(1);
}

export function registerBabysitCommand(program: Command): void {
const babysit = program
.command("babysit")
.description("Inspect + control the PR-babysit scheduler");

babysit
.command("register <url>")
.description("Register a GitHub PR URL for babysitting")
.option(
"--installation-id <n>",
"GitHub App installation id (enables App-auth writes)",
)
.action(async (url: string, opts: { installationId?: string }) => {
const installationId =
opts.installationId !== undefined
? Number(opts.installationId)
: undefined;
if (installationId !== undefined && !Number.isInteger(installationId)) {
console.log(header(sym.err, "--installation-id must be an integer"));
process.exit(1);
}
const res = await callDaemon<{ job: JobRow; created: boolean }>(
"/babysit/jobs",
{
method: "POST",
body: { url, installationId },
},
);
if (!res.ok) dieWithError(res.error);
const { job, created } = res.data;
console.log("");
console.log(
header(
sym.ok,
created ? "Registered for babysitting" : "Already registered",
job.id,
),
);
console.log("");
console.log(
kv([
["State", stateColor(job.state)],
["Repo", c.cyan(job.repo)],
["PR #", c.cyan(String(job.pr_number))],
[
"Installation",
job.installation_id === null
? c.dim("none (CLI-auth fallback)")
: c.cyan(String(job.installation_id)),
],
]),
);
console.log("");
});

babysit
.command("list")
.description("List babysit jobs")
.option("--active", "Only show non-terminal jobs")
.option("--state <state>", "Filter by state (idle, judging, paused, ...)")
.action(async (opts: { active?: boolean; state?: string }) => {
const query = new URLSearchParams();
if (opts.active) query.set("active", "true");
if (opts.state) query.set("state", opts.state);
const qs = query.toString();
const res = await callDaemon<{ items: JobRow[]; total: number }>(
`/babysit/jobs${qs ? "?" + qs : ""}`,
);
if (!res.ok) dieWithError(res.error);
const items = res.data.items;
console.log("");
if (items.length === 0) {
console.log(header(sym.info, "No babysit jobs"));
console.log("");
return;
}
console.log(
header(
sym.bullet,
`${items.length} job${items.length === 1 ? "" : "s"}`,
),
);
console.log("");
// Compact table. Show id, state, fix_commits, updated_at-relative.
const rows: Array<[string, string]> = items.map((j) => [
j.id,
`${stateColor(j.state).padEnd(20)} ${c.dim("fixes=" + j.fix_commits)} ${c.dim(relTime(j.updated_at))}`,
]);
console.log(kv(rows));
console.log("");
});

babysit
.command("show <id>")
.description("Show a babysit job + its decision log")
.action(async (id: string) => {
const res = await callDaemon<{
job: JobRow;
decisions: DecisionRow[];
}>(`/babysit/jobs/${encodeURIComponent(id)}`);
if (!res.ok) dieWithError(res.error);
const { job, decisions } = res.data;
console.log("");
console.log(header(sym.bullet, job.id, stateColor(job.state)));
console.log("");
console.log(
kv([
["Repo", c.cyan(job.repo)],
["PR #", c.cyan(String(job.pr_number))],
["Started", c.dim(relTime(job.started_at))],
["Updated", c.dim(relTime(job.updated_at))],
[
"Ended",
job.ended_at === null ? c.dim("—") : c.dim(relTime(job.ended_at)),
],
["Fix commits", c.cyan(String(job.fix_commits))],
["Judge calls", c.dim(String(job.total_judge_calls))],
["Fix calls", c.dim(String(job.total_fix_calls))],
[
"Worktree",
job.worktree_path === null ? c.dim("—") : c.dim(job.worktree_path),
],
[
"Escalation",
job.escalation_reason === null
? c.dim("—")
: c.red(job.escalation_reason),
],
]),
);
console.log("");
if (decisions.length === 0) {
console.log(` ${c.dim("No comment decisions yet.")}`);
console.log("");
return;
}
console.log(
` ${c.bold("Decisions")} ${c.dim("(" + decisions.length + ")")}`,
);
console.log("");
for (const d of decisions) {
const validityColored =
d.validity === "valid" ? c.green(d.validity) : c.dim(d.validity);
const outcome = d.outcome ?? "—";
console.log(
` ${sym.arrow} ${c.cyan(String(d.comment_id))} ${c.dim(d.comment_author)} ${validityColored} ${c.dim(d.category)} ${c.dim("→")} ${outcome}`,
);
}
console.log("");
});

babysit
.command("pause <id>")
.description("Pause a babysit job (scheduler will skip it)")
.action(async (id: string) => {
const res = await callDaemon<{ job: JobRow }>(
`/babysit/jobs/${encodeURIComponent(id)}`,
{ method: "PATCH", body: { action: "pause" } },
);
if (!res.ok) dieWithError(res.error);
console.log("");
console.log(header(sym.ok, "Paused", res.data.job.id));
console.log("");
});

babysit
.command("resume <id>")
.description("Resume a paused babysit job")
.action(async (id: string) => {
const res = await callDaemon<{ job: JobRow }>(
`/babysit/jobs/${encodeURIComponent(id)}`,
{ method: "PATCH", body: { action: "resume" } },
);
if (!res.ok) dieWithError(res.error);
console.log("");
console.log(header(sym.ok, "Resumed", res.data.job.id + " → idle"));
console.log("");
});
}
Loading