diff --git a/README.md b/README.md index e490ef9..3c2b2b8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/skills/postmortemthis/SKILL.md b/skills/postmortemthis/SKILL.md index 9c6bb16..4aa7bc6 100644 --- a/skills/postmortemthis/SKILL.md +++ b/skills/postmortemthis/SKILL.md @@ -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 diff --git a/src/agents.rs b/src/agents.rs index 0af9d45..9b95b2c 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -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, @@ -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 @@ -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 { + 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). @@ -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 diff --git a/src/main.rs b/src/main.rs index 32069ba..ab11cac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 @@ -156,8 +157,17 @@ fn run(args: RunArgs) -> Result<()> { selected.iter().map(|a| a.name()).collect::>().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. @@ -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", @@ -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 { +fn run_notes( + reports: &[Report], + selected: &[Agent], + settings: &settings::Settings, + tree_touched: bool, +) -> Vec { 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 { @@ -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 @@ -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 { + 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], diff --git a/src/runner.rs b/src/runner.rs index 1fda11a..07fd33f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -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);