Skip to content

Commit 2567a4a

Browse files
committed
feat(extensions): add /gate background branch validation command
Spins out a background pi agent in a dedicated git worktree that runs the pipeline (intent β†’ rebase β†’ review β†’ diagnostics β†’ test β†’ comment-cleanup β†’ lint, auto-fixing safe issues) against the resolved default branch, then lands the gated result once the branch is no longer checked out.
1 parent 8360dab commit 2567a4a

4 files changed

Lines changed: 413 additions & 5 deletions

File tree

β€Ž.pi/agent/extensions/AGENTS.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Read referenced `.md` files completely and follow their cross-references before
4444
### Workflow shortcuts
4545

4646
- **`resend.ts`** β€” `/resend` re-runs the agent on the current transcript with nothing appended. For when you abort a prompt mid-stream or it stalls and auto-retry gives up: restarts inference on your message as-is, no duplicate.
47+
- **`gate.ts`** β€” `/gate [intent]` spins out a background agent that validates the current branch (intent β†’ rebase β†’ review β†’ lsp diagnostics β†’ test β†’ comment-cleanup β†’ lint, auto-fixing safe issues) and, on a clean verdict, lands the gated result back onto the branch once it's no longer checked out β€” your checkout stays free to keep working or switch branches meanwhile.
4748
- **`yeet.ts`** β€” `/yeet` stages, commits with an auto-written Conventional Commits message (informed by the recent conversation for intent), and pushes.
4849
- **`copy.ts`** β€” `/copy-blocks` picks a fenced code block from the last assistant response; `/copy-all` copies the full session as markdown. Built-in `/copy` unchanged.
4950
- **`auto-rename.ts`** β€” names the session after 3+ turns. Kills the default `2025-05-24T09-21-…` slugs.

β€Ž.pi/agent/extensions/gate.tsβ€Ž

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
// /gate β€” validate the current branch in the background, then land the result.
2+
//
3+
// Spins out a background pi agent (full toolset + harness extensions, same as
4+
// the main agent) that works in a dedicated git worktree on a temp branch off
5+
// the current branch, runs a no-mistakes-style pipeline (intent β†’ rebase β†’
6+
// review β†’ diagnostics β†’ test β†’ comment-cleanup β†’ lint, auto-fixing safe
7+
// issues), and on a clean verdict updates
8+
// the original branch to the gated result. Your checkout is never blocked: keep
9+
// working or switch branches, and the gate lands the branch once it's no longer
10+
// checked out. Git refuses to move a checked-out branch ref, so if you stay on
11+
// the branch the gated work is left on the temp branch for you to land manually.
12+
13+
import { spawn } from "node:child_process";
14+
import * as fs from "node:fs";
15+
import * as os from "node:os";
16+
import * as path from "node:path";
17+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18+
import { extractText } from "./shared/message";
19+
20+
// Watchdogs for a background gate that hangs. idle: no event AND no tool in
21+
// flight (a long test emits start, then silence until end, so gate on "no tool
22+
// running"). wall-clock: absolute cap that also catches a hung tool.
23+
const IDLE_TIMEOUT_MS = 180_000;
24+
const MAX_DURATION_MS = 1_800_000;
25+
const HARD_KILL_MS = 3_000;
26+
// A crash before the close handler runs can orphan a worktree; anything older
27+
// than the wall-clock cap is past the watchdog, so reaping it can't kill a live
28+
// run.
29+
const ORPHAN_AGE_MS = MAX_DURATION_MS + 5 * 60_000;
30+
31+
// In-session guard against a second gate on the same branch.
32+
const active = new Set<string>();
33+
34+
const GATE_SYSTEM_PROMPT = (defaultRef: string) =>
35+
`You are a release gate running inside a dedicated git worktree on a temp branch. Validate and fix THIS worktree's changes. Do NOT touch any other branch, do NOT remove this worktree, do NOT push.
36+
37+
Steps in order. Each PASSES, applies a SAFE auto-fix (commit it here), or ESCALATES (needs a human call):
38+
39+
1. intent - establish what this branch is FOR before changing anything: read the commits and the diff vs ${defaultRef} (use supplied author intent if given) and state the purpose in one sentence. Carry it as context through every later step.
40+
2. rebase - git fetch, then rebase this branch onto ${defaultRef}. Resolve only mechanical conflicts; ESCALATE the rest.
41+
3. review - read the full diff vs ${defaultRef}. Judge it against the stated intent. Flag correctness, reliability, security issues. ESCALATE anything intent-sensitive (questioning a deliberate design/product choice, undoing an intentional add/remove). Never silently rewrite intent.
42+
4. diagnostics - run lsp_diagnostics over the changed files. Auto-fix every error/warning at the root cause (no suppress directives). ESCALATE anything that can't be fixed without changing intent.
43+
5. test - detect and run the project's test command. Auto-fix mechanical failures. ESCALATE a real behavior gap or an undeterminable command.
44+
6. comments - review comments in the changed code and clean them to a concise style: WHY not WHAT, drop redundant/obvious comments, drop history ("replaces", "legacy", "previously"), no decorative dividers or banners, no em-dashes. Edit comments only, never behavior. Do NOT add docs or doc-comments.
45+
7. lint - detect and run linters/formatters; apply safe fixes.
46+
47+
Rules:
48+
- AUTO-FIX = objective, mechanical, no intent change. Commit here with message prefix "gate(<step>): <summary>".
49+
- ESCALATE = anything needing human judgment.
50+
51+
Write a concise human report. Then, as the LAST line of your final message, emit EXACTLY one machine-readable line:
52+
GATE_RESULT: {"verdict":"green"|"attention","summary":"<one line>","findings":[{"step":"...","severity":"error"|"warning"|"info","action":"auto-fix"|"ask-user"|"no-op","desc":"..."}]}
53+
verdict is "green" ONLY if every step passed or was safely auto-fixed and no finding needs a human call.`;
54+
55+
interface GateResult {
56+
verdict: string;
57+
summary?: string;
58+
findings?: {
59+
step?: string;
60+
severity?: string;
61+
action?: string;
62+
desc?: string;
63+
}[];
64+
}
65+
66+
function parseResult(report: string): GateResult | null {
67+
const m = report.match(/^GATE_RESULT:\s*(\{.*\})\s*$/m);
68+
if (!m) return null;
69+
try {
70+
return JSON.parse(m[1]) as GateResult;
71+
} catch {
72+
return null;
73+
}
74+
}
75+
76+
const stripResultLine = (s: string) =>
77+
s.replace(/^GATE_RESULT:.*$/m, "").trim();
78+
79+
// Kill the child's whole process group (it spawns grandchildren: nvim, tests).
80+
function killGroup(
81+
child: { pid?: number; kill: (s: NodeJS.Signals) => boolean },
82+
sig: NodeJS.Signals,
83+
) {
84+
if (typeof child.pid === "number") {
85+
try {
86+
process.kill(-child.pid, sig);
87+
return;
88+
} catch {
89+
/* group gone β€” fall through */
90+
}
91+
}
92+
try {
93+
child.kill(sig);
94+
} catch {
95+
/* already dead */
96+
}
97+
}
98+
99+
type Git = (
100+
...g: string[]
101+
) => Promise<{ ok: boolean; out: string; err: string }>;
102+
103+
export default function (pi: ExtensionAPI) {
104+
const makeGit =
105+
(cwd: string): Git =>
106+
async (...g: string[]) => {
107+
const r = await pi.exec("git", ["-c", "color.ui=never", ...g], { cwd });
108+
return { ok: r.code === 0, out: r.stdout.trim(), err: r.stderr.trim() };
109+
};
110+
111+
// Reap worktrees/branches left by a gate whose session crashed before its
112+
// close handler could clean up. Only stale (past-watchdog) gate worktrees are
113+
// touched, so a gate still running in another session is left alone.
114+
const reapOrphans = async (cwd: string) => {
115+
const git = makeGit(cwd);
116+
if (!(await git("rev-parse", "--git-dir")).ok) return;
117+
await git("worktree", "prune");
118+
const list = (await git("worktree", "list", "--porcelain")).out;
119+
for (const block of list.split("\n\n")) {
120+
const wpath = block.match(/^worktree (.+)$/m)?.[1];
121+
const ref = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
122+
if (!wpath || !ref?.startsWith("gate/")) continue;
123+
if (!path.basename(wpath).startsWith("pi-gate-")) continue;
124+
let stale = true;
125+
try {
126+
stale = Date.now() - fs.statSync(wpath).mtimeMs > ORPHAN_AGE_MS;
127+
} catch {
128+
stale = true; // dir gone β€” metadata-only orphan
129+
}
130+
if (!stale) continue;
131+
await git("worktree", "remove", "--force", wpath);
132+
await git("branch", "-D", ref);
133+
}
134+
};
135+
136+
pi.on("session_start", (_e, ctx) => void reapOrphans(ctx.cwd));
137+
138+
pi.registerCommand("gate", {
139+
description:
140+
"Validate the current branch in a background agent, then land it",
141+
handler: async (args, ctx) => {
142+
const cwd = ctx.cwd;
143+
const git = makeGit(cwd);
144+
145+
if (!(await git("rev-parse", "--git-dir")).ok) {
146+
ctx.ui.notify("/gate: not a git repository", "error");
147+
return;
148+
}
149+
const branchRes = await git("symbolic-ref", "--quiet", "--short", "HEAD");
150+
if (!branchRes.ok) {
151+
ctx.ui.notify("/gate: detached HEAD, no branch to gate", "error");
152+
return;
153+
}
154+
const branch = branchRes.out;
155+
if (active.has(branch)) {
156+
ctx.ui.notify(`/gate already running on '${branch}'`, "warning");
157+
return;
158+
}
159+
160+
// Resolve the remote's default branch authoritatively (no hardcoded
161+
// name): the locally cached origin/HEAD, else ask the remote directly.
162+
let def = (
163+
await git(
164+
"symbolic-ref",
165+
"--quiet",
166+
"--short",
167+
"refs/remotes/origin/HEAD",
168+
)
169+
).out.replace(/^origin\//, "");
170+
if (!def) {
171+
const sym = await git("ls-remote", "--symref", "origin", "HEAD");
172+
def = sym.out.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m)?.[1] ?? "";
173+
}
174+
if (!def) {
175+
for (const cand of ["main", "master"]) {
176+
if ((await git("rev-parse", "--verify", `origin/${cand}`)).ok) {
177+
def = cand;
178+
break;
179+
}
180+
}
181+
}
182+
if (!def) {
183+
ctx.ui.notify("/gate: cannot determine default branch", "error");
184+
return;
185+
}
186+
const defaultRef = `origin/${def}`;
187+
188+
// Fetch fresh so the no-diff check and the agent's rebase see latest.
189+
await git("fetch", "origin", def);
190+
191+
const ahead = await git("rev-list", "--count", `${defaultRef}..HEAD`);
192+
if (ahead.ok && ahead.out === "0") {
193+
ctx.ui.notify(
194+
`/gate: nothing to gate (no commits ahead of ${defaultRef})`,
195+
"warning",
196+
);
197+
return;
198+
}
199+
200+
const startSha = (await git("rev-parse", "HEAD")).out;
201+
if ((await git("status", "--porcelain")).out) {
202+
ctx.ui.notify(
203+
"/gate: uncommitted changes won't be gated (gating HEAD)",
204+
"warning",
205+
);
206+
}
207+
208+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
209+
const tmpBranch = `gate/${branch}-${ts}`;
210+
const wtDir = path.join(os.tmpdir(), `pi-gate-${ts}`);
211+
212+
const add = await git("worktree", "add", "-b", tmpBranch, wtDir, "HEAD");
213+
if (!add.ok) {
214+
ctx.ui.notify(`/gate: worktree add failed: ${add.err}`, "error");
215+
return;
216+
}
217+
active.add(branch);
218+
219+
const intent = args?.trim();
220+
const task = intent
221+
? `Gate this worktree. Author intent: ${intent}`
222+
: "Gate this worktree. Infer intent from the diff.";
223+
224+
const child = spawn(
225+
"pi",
226+
[
227+
"-p",
228+
task,
229+
"--mode",
230+
"json",
231+
"--no-session",
232+
"--system-prompt",
233+
GATE_SYSTEM_PROMPT(defaultRef),
234+
],
235+
{
236+
cwd: wtDir,
237+
stdio: ["ignore", "pipe", "pipe"],
238+
windowsHide: true,
239+
detached: true,
240+
// Recursion guard only β€” disables the `subagent` tool. All other
241+
// tools and harness extensions (lsp, eval, codegraph, …) load.
242+
env: { ...process.env, PI_IS_SUBAGENT: "1" },
243+
},
244+
);
245+
child.unref();
246+
247+
let outBuf = "";
248+
let stderrBuf = "";
249+
let report = "";
250+
let lastEventAt = Date.now();
251+
let runningTools = 0;
252+
let killedReason = "";
253+
let exited = false;
254+
255+
const watchdog = setInterval(() => {
256+
if (exited || killedReason) return;
257+
const now = Date.now();
258+
if (now - lastEventAt > MAX_DURATION_MS) killedReason = "timeout";
259+
else if (runningTools === 0 && now - lastEventAt > IDLE_TIMEOUT_MS)
260+
killedReason = "idle";
261+
if (killedReason) {
262+
killGroup(child, "SIGTERM");
263+
const t = setTimeout(() => {
264+
if (!exited) killGroup(child, "SIGKILL");
265+
}, HARD_KILL_MS);
266+
t.unref?.();
267+
}
268+
}, 5_000);
269+
watchdog.unref?.();
270+
271+
child.stdout.on("data", (c: Buffer) => {
272+
outBuf += c.toString("utf-8");
273+
let i: number;
274+
while ((i = outBuf.indexOf("\n")) >= 0) {
275+
const line = outBuf.slice(0, i);
276+
outBuf = outBuf.slice(i + 1);
277+
if (!line.trim()) continue;
278+
lastEventAt = Date.now();
279+
let ev: {
280+
type?: string;
281+
message?: { role?: string; content?: unknown };
282+
};
283+
try {
284+
ev = JSON.parse(line);
285+
} catch {
286+
continue;
287+
}
288+
if (ev.type === "tool_execution_start") runningTools++;
289+
else if (ev.type === "tool_execution_end")
290+
runningTools = Math.max(0, runningTools - 1);
291+
else if (
292+
ev.type === "message_end" &&
293+
ev.message?.role === "assistant"
294+
) {
295+
const text = extractText(ev.message.content);
296+
if (text.trim()) report = text;
297+
}
298+
}
299+
});
300+
child.stderr.on("data", (c: Buffer) => {
301+
stderrBuf += c.toString("utf-8");
302+
});
303+
304+
child.on("error", (err) => {
305+
ctx.ui.notify(`/gate failed to start: ${err.message}`, "error");
306+
});
307+
308+
child.on("close", async (code) => {
309+
exited = true;
310+
clearInterval(watchdog);
311+
active.delete(branch);
312+
313+
const result = parseResult(report);
314+
const human = stripResultLine(report);
315+
const resultSha = (await git("rev-parse", tmpBranch)).out;
316+
const removeWt = () => git("worktree", "remove", "--force", wtDir);
317+
318+
const finish = async (note: string, dropBranch: boolean) => {
319+
await removeWt();
320+
if (dropBranch) await git("branch", "-D", tmpBranch);
321+
const body =
322+
human ||
323+
(killedReason
324+
? `gate ${killedReason === "timeout" ? "exceeded its time budget" : "stalled"} and was stopped.`
325+
: `gate exited (${code}).`) +
326+
(stderrBuf.trim() ? `\n\n${stderrBuf.trim().slice(-1000)}` : "");
327+
pi.sendUserMessage(
328+
`Background /gate (${branch}):\n\n${body}\n\n${note}`,
329+
{
330+
deliverAs: "followUp",
331+
},
332+
);
333+
};
334+
335+
if (killedReason) {
336+
await finish(
337+
`Stopped (${killedReason}). Partial work left on '${tmpBranch}'.`,
338+
false,
339+
);
340+
return;
341+
}
342+
if (!result || result.verdict !== "green") {
343+
const why = !result
344+
? "no machine-readable verdict"
345+
: "NEEDS ATTENTION";
346+
await finish(`Verdict: ${why}. Work left on '${tmpBranch}'.`, false);
347+
return;
348+
}
349+
350+
// Land: only if the source branch hasn't moved and isn't checked out.
351+
const nowSha = (await git("rev-parse", branch)).out;
352+
if (nowSha !== startSha) {
353+
await finish(
354+
`'${branch}' moved since gate started β€” not landing. Gated result on '${tmpBranch}'.`,
355+
false,
356+
);
357+
return;
358+
}
359+
const wtList = (await git("worktree", "list", "--porcelain")).out;
360+
if (wtList.includes(`branch refs/heads/${branch}\n`)) {
361+
await finish(
362+
`GREEN, but '${branch}' is still checked out so its ref can't move. ` +
363+
`Switch off it, then: git checkout ${branch} && git reset --hard ${tmpBranch} ` +
364+
`(gated result on '${tmpBranch}').`,
365+
false,
366+
);
367+
return;
368+
}
369+
const land = await git(
370+
"update-ref",
371+
`refs/heads/${branch}`,
372+
resultSha,
373+
startSha,
374+
);
375+
if (!land.ok) {
376+
await finish(
377+
`Land failed: ${land.err}. Gated result on '${tmpBranch}'.`,
378+
false,
379+
);
380+
return;
381+
}
382+
await finish(
383+
`Landed on '${branch}' (${startSha.slice(0, 8)} β†’ ${resultSha.slice(0, 8)}).`,
384+
true,
385+
);
386+
});
387+
388+
ctx.ui.notify(`/gate running on '${branch}' in the background`, "info");
389+
},
390+
});
391+
}

0 commit comments

Comments
Β (0)