Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Why really?** The agent that wrote your code is the worst judge of it.

**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call.
**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call. (Read-only is enforced by each agent's own CLI - except Antigravity, which has no such switch and is held to it by its plan mode instead. If the tree moves during a run, you get told.)

**How?** One tiny script that installs, updates, and runs all the agents for you, on Windows, macOS, and Linux. No setup, no server, no MCP.

Expand Down
5 changes: 5 additions & 0 deletions skills/postmortemthis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ skill builds the prompt, runs the fan-out, and synthesizes the answers.
It is **read-only by default**: it reports what the agents say, it does not edit, stage,
commit, or push. Applying fixes is a separate, explicit step.

Each agent is held read-only by its own CLI - a sandbox, a tool allowlist, a deny-on-approval
mode - with one exception: Antigravity has no such switch, so it runs in its plan mode, which
refuses to write but is a persona rather than a sandbox. The wrapper compares `git status`
before and after the run and says so in the run notes if the tree moved.

## 1. Decide what to ask

The prompt is whatever the user wants several agents to weigh in on. There is **no git
Expand Down
90 changes: 78 additions & 12 deletions src/agents.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
use crate::gg;
use crate::openrouter;
use crate::vibe;
use std::borrow::Cow;
use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;

/// A supported agent CLI. Each runs headless, read-only, in the repo's cwd,
/// using its own native harness and whatever auth the user already has.
/// Appended to Antigravity's prompt only; see `Agent::decorate_prompt`. The
/// "do not modify" half earns its place: read-only on that leg is plan mode's
/// persona, so skipping the plan file without it reads as "stop planning".
const ANTIGRAVITY_CODA: &str = "\n\n---\nPut your findings in this reply: only stdout is \
captured, so a plan or artifact file is not read back. Do not modify anything in the \
workspace - this is a review, not a change.";

/// A supported agent CLI. Each runs headless in the repo's cwd, using its own
/// native harness and whatever auth the user already has. All read-only, but only
/// five by their own CLI - Antigravity has no such switch and rides on its plan
/// mode instead (see `args`), so main.rs watches the tree for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
Expand Down Expand Up @@ -168,16 +179,46 @@ impl Agent {
// and the runner appends the prompt (see reads_stdin). Ok as an argv
// operand: gg ships antigravity as a native binary, not a .cmd shim,
// so multiline survives.
// Read-only rests on the print-mode default: WITHOUT
// --dangerously-skip-permissions, any write tool is diverted into
// Antigravity's own scratch dir and never touches the workspace
// (verified empirically), while file reads and read-only git run
// against the real cwd. The explicit `--sandbox` flag is
// deliberately NOT used: it hangs headless `-p` runs until the
// print-timeout fires. --print-timeout is parked far above any
// realistic outer --timeout so postmortemthis's own timeout governs,
// not Antigravity's 5m print-mode default.
Agent::Antigravity => vec!["--print-timeout", "24h", "-p"],
//
// --add-dir (appended by `command`, which knows the repo):
// Antigravity ignores the process cwd. Print mode opens no workspace
// at all and works out of ~/.gemini/antigravity-cli/scratch instead -
// one run went looking for the repo with `find $HOME -maxdepth 3`.
//
// --mode plan is what holds this leg read-only. The print-mode
// default does not - it overwrites workspace files happily. Plan mode
// refused both a straight "overwrite this, do not plan" and the same
// thing through the terminal. It is a persona doing the refusing
// though, not a sandbox; a hard guarantee means a throwaway worktree.
// `--sandbox` is not it either - that only restricts the terminal,
// the write tools go right through.
//
// NOT --disable-slash-commands, however tempting: a prompt starting
// with `/` gets eaten as a slash command, but plan mode is built on
// that same expansion and the flag silently voids it ("warning:
// --mode plan has no effect while slash command expansion is
// disabled"), leaving skip-permissions on with nothing holding the
// tree. A leading `/` costs one leg, a voided plan mode costs the
// read-only property.
//
// --dangerously-skip-permissions: headless soft-denies every tool
// that needs approval (since 1.1.3), which under the stock
// `toolPermission: request-review` is every shell command. Without it
// the leg dies either way - bare `-p` exits 1 on "permission check
// failed", under --sandbox it exits 0 having printed nothing. It
// drops the confirmation, not the read-only; that is plan mode's job.
//
// --print-timeout is parked far above any realistic outer --timeout
// so postmortemthis's own timeout governs, not Antigravity's 5m
// print-mode default.
Agent::Antigravity => vec![
"--print-timeout",
"24h",
"--mode",
"plan",
"--dangerously-skip-permissions",
"-p",
],
// Qwen Code is a Gemini-CLI fork: same read-only approval model.
// --auth-type openai pins it to the OpenAI-compatible endpoint
// (the OPENAI_* env points that at OpenRouter); the prompt is read
Expand All @@ -200,6 +241,30 @@ impl Agent {
}
}

/// The prompt as this agent's CLI should receive it. Antigravity's plan mode
/// (see `args`) is told by its own system prompt to put the findings in an
/// artifact file and not repeat them in the reply, so the review landed in
/// its brain dir and stdout got "I have created a plan, please review it".
/// The coda pulls it back into the reply. Everyone else gets it untouched.
pub fn decorate_prompt<'a>(&self, prompt: &'a str) -> Cow<'a, str> {
match self {
Agent::Antigravity => Cow::Owned(format!("{prompt}{ANTIGRAVITY_CODA}")),
_ => Cow::Borrowed(prompt),
}
}

/// Args that hand this agent the repo as its workspace, for a CLI that does
/// not take one from the process cwd. Only Antigravity needs it (see `args`);
/// the rest work off current_dir.
fn workspace_args(&self, repo: &Path) -> Vec<OsString> {
match self {
// OsString, not String: a repo path need not be UTF-8, and
// to_string_lossy() hands Antigravity a U+FFFD path that opens nothing
Agent::Antigravity => vec!["--add-dir".into(), repo.as_os_str().to_os_string()],
_ => vec![],
}
}

/// The base command that reaches this agent's CLI - through gg when a gg is
/// available, else the native binary on PATH. Callers layer their own args
/// on top (`command` for a review, `login_command` for a login).
Expand All @@ -225,6 +290,7 @@ impl Agent {
codex_exec_command(repo, if openrouter { self.openrouter_model() } else { None })
} else {
let mut cmd = runner.base_command();
cmd.args(self.workspace_args(repo));
cmd.args(self.args());
cmd.current_dir(repo);
cmd
Expand Down
52 changes: 50 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use clap::{Parser, Subcommand};
use runner::{Outcome, Report};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

/// Version reported by `--version`: the git tag baked in at release build
Expand Down Expand Up @@ -156,8 +157,17 @@ fn run(args: RunArgs) -> Result<()> {
selected.iter().map(|a| a.name()).collect::<Vec<_>>().join(", ")
);

// Antigravity's leg is held read-only by a persona, not a sandbox (see
// agents::Agent::args), so "it did not write" is an assumption about a CLI
// that moves fast. Better to notice than to trust a comment.
let tree_before = tree_state(&cwd);

let reports = execute(&selected, &skip_native, &prompt, &cwd, timeout)?;

let tree_touched = selected.contains(&Agent::Antigravity)
&& tree_before.is_some()
&& tree_state(&cwd) != tree_before;

// When --out is set, write each agent's output to a file and print the
// paths first, so they survive even if the stdout body is later truncated
// by the caller. stdout still carries the full sections by default.
Expand All @@ -175,7 +185,7 @@ fn run(args: RunArgs) -> Result<()> {
print!("\n\n{}", report_section(r));
}

let notes = run_notes(&reports, &selected, &settings);
let notes = run_notes(&reports, &selected, &settings, tree_touched);
if !notes.is_empty() {
print!(
"\n\n---\n\n# postmortemthis run notes (operational; not part of the review)\n\n{}\n",
Expand Down Expand Up @@ -265,12 +275,28 @@ fn strip_ansi(s: &str) -> String {
out
}

/// Under this many characters, an agent that exited cleanly probably answered
/// with a placeholder, not a review. A terse-but-real reply costs one note; a
/// lost leg costs the caller an opinion it thinks it had.
const THIN_OUTPUT: usize = 300;

/// Operational notes for the calling agent: what it can fix or change on a
/// later run. Empty when there is nothing worth saying. Kept terse and
/// imperative because the consumer is an LLM composing the next command.
fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settings) -> Vec<String> {
fn run_notes(
reports: &[Report],
selected: &[Agent],
settings: &settings::Settings,
tree_touched: bool,
) -> Vec<String> {
let mut notes = Vec::new();

if tree_touched {
notes.push(
"- the working tree changed while the agents ran. Every leg but antigravity is held read-only by its own CLI; antigravity's plan mode is a persona, not a sandbox, so it is the one to suspect. Run `git status` and `git diff` before trusting the review above.".to_string(),
);
}

for r in reports {
let name = r.agent.name();
if r.fell_back {
Expand All @@ -285,6 +311,16 @@ fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settin
"- {name}: timed out. Raise --timeout or shorten the prompt."
));
}
// Exit status is a poor test of "answered": an agent can exit 0 having
// printed "I have created a plan, please review it", or nothing at all
// (claude via OpenRouter, see openrouter_env). Both render as a normal
// section, so the caller reads a silent leg as one with nothing to add.
let len = r.output.trim().chars().count();
if r.outcome == Outcome::Ok && len < THIN_OUTPUT {
notes.push(format!(
"- {name}: exited cleanly but returned only {len} characters. That is usually a placeholder ('I have created a plan...'), not an answer - read its section before counting it as an opinion, and re-run that agent alone if you need it."
));
}
}

// Agents that exist but were not run for lack of usable credentials. An
Expand Down Expand Up @@ -416,6 +452,18 @@ fn plan_run(
/// Bring up per-run scratch state (Vibe's VIBE_HOME), prewarm the gg tools, and
/// fan out. Shared by `run` and `setup`'s test so both take the same path; the
/// Vibe home guard is held until run_all returns.
/// `git status --porcelain` for the review cwd: a cheap tree fingerprint,
/// compared before and after a run. None outside a git repo - postmortemthis
/// runs in plain folders too. Never printed.
fn tree_state(cwd: &Path) -> Option<String> {
let out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(cwd)
.output()
.ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}

fn execute(
selected: &[Agent],
skip_native: &[Agent],
Expand Down
2 changes: 2 additions & 0 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ fn run_attempt(
) -> Report {
let started = Instant::now();
let mut cmd = agent.command(repo, openrouter);
let prompt = agent.decorate_prompt(prompt);
let prompt = prompt.as_ref();
// Most read the prompt on stdin; vibe (always) and grok's native leg take it
// as a trailing argument and get no stdin.
let on_stdin = agent.reads_stdin(openrouter);
Expand Down
Loading