Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents as a team: lifecycle, merge queue, billing and a new shell | 1 | //! Turns an outcome someone wrote into a plan: the issues that would get |
| 2 | //! there, what each must pass, which files each will touch, and which must | |
| 3 | //! land before which. | |
| 4 | //! | |
| 5 | //! The agent reads the repository to do it, so the plan is about the code | |
| 6 | //! as it is and not a guess. It writes the plan to a file as JSON and this | |
| 7 | //! program posts it to g1t, where a person reads and edits it before | |
| 8 | //! anything is opened. The agent never holds the credential that reports | |
| 9 | //! the plan, and changes nothing. | |
| 10 | //! | |
| 11 | //! Configuration comes from the environment: | |
| 12 | //! | |
| 13 | //! - `G1T_API`, `PLAN_ID`, `PLAN_TOKEN`: where and how to report. | |
| 14 | //! - `GIT_REMOTE`: the repository to plan for. | |
| 15 | //! - `G1T_USER`, `G1T_TOKEN`: to read it, if it is private. | |
| 16 | //! - `PROMPT`: the outcome wanted. | |
| 17 | ||
| 18 | use std::path::Path; | |
| 19 | ||
| 20 | use anyhow::{Context, Result, bail}; | |
| 21 | use serde_json::{Value, json}; | |
| 22 | ||
| 23 | use crate::report::Reporter; | |
| 24 | use crate::{WORKDIR, auth_option, env, git, harness}; | |
| 25 | ||
| 26 | const PLAN_FILE: &str = "/work/plan.json"; | |
| 27 | ||
| 28 | const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \ | |
| 29 | Read enough of it to understand how it is built and tested. Do not modify it. | |
| 30 | ||
| 31 | Split the outcome below into issues. Each issue will be given to a separate agent that sees only that issue and the repository, \ | |
| 32 | and each will be merged on its own. So: | |
| 33 | ||
| 34 | - Make each issue one coherent change that can be merged by itself and leaves the project working. | |
| 35 | - Write the body for someone with no other context: what to change, where, and why. Name the files and functions involved. | |
| 36 | - Prefer several small issues to one large one, but do not split a change that only makes sense whole. At most 12 issues. | |
| 37 | - Give acceptance checks: shell commands that must pass once the change is made. Use the project's real test or build commands. \ | |
| 38 | Give none if the project has no way to check that kind of change. | |
| 39 | - List the files each issue will most likely change. | |
| 40 | - Two agents working at once must not edit the same code. If two issues would change the same file, or one needs what another adds, \ | |
| 41 | make the later one depend on the earlier. Otherwise leave them independent, so that they are worked on at the same time. | |
| 42 | ||
| 43 | Write the plan to /work/plan.json as JSON with exactly this shape: | |
| 44 | ||
| 45 | { | |
| 46 | \"summary\": \"Two or three sentences: how you split the outcome and why in this order.\", | |
| 47 | \"issues\": [ | |
| 48 | { | |
| 49 | \"title\": \"One line, as an instruction\", | |
| 50 | \"body\": \"Markdown.\", | |
| 51 | \"labels\": [\"feature\"], | |
| 52 | \"checks\": [\"cargo test\"], | |
| 53 | \"files\": [\"src/lib.rs\"], | |
| 54 | \"depends_on\": [1] | |
| 55 | } | |
| 56 | ] | |
| 57 | } | |
| 58 | ||
| 59 | `labels` are from: bug, feature, docs, chore. `depends_on` holds the positions, counting from 1, of earlier issues in this list that must be merged first; \ | |
| 60 | it is empty for an issue that can start at once. An issue may only depend on issues before it. Then finish. | |
| 61 | ||
| 62 | The outcome wanted:"; | |
| 63 | ||
| 64 | fn plan() -> Result<Value> { | |
| 65 | let remote = env("GIT_REMOTE")?; | |
| 66 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); | |
| 67 | let workdir = Path::new(WORKDIR); | |
| 68 | ||
| 69 | std::fs::create_dir_all("/work")?; | |
| 70 | git( | |
| 71 | Path::new("/work"), | |
| 72 | &["-c", &auth, "clone", "--quiet", &remote, WORKDIR], | |
| 73 | ) | |
| 74 | .context("could not clone the repository")?; | |
| 75 | ||
| 76 | let prompt = format!("{INSTRUCTIONS}\n\n{}", env("PROMPT")?); | |
| 77 | // A plan has no session; what matters is the plan. | |
| 78 | let mut reporter = Reporter::silent(); | |
| 79 | let answer = harness::run_claude(workdir, &prompt, &mut reporter)?; | |
| 80 | ||
| 81 | let written = std::fs::read_to_string(PLAN_FILE).unwrap_or_default(); | |
| 82 | let plan: Value = serde_json::from_str(&written) | |
| 83 | .ok() | |
| 84 | .filter(|plan: &Value| plan["issues"].is_array()) | |
| 85 | // The agent may have answered with the plan instead of writing it. | |
| 86 | .or_else(|| embedded_json(&answer).filter(|plan| plan["issues"].is_array())) | |
| 87 | .context("the agent did not produce a plan")?; | |
| 88 | if plan["issues"].as_array().is_some_and(Vec::is_empty) { | |
| 89 | bail!("the agent proposed no issues"); | |
| 90 | } | |
| 91 | Ok(plan) | |
| 92 | } | |
| 93 | ||
| 94 | /// The JSON object in a piece of text, from its first brace to its last. | |
| 95 | fn embedded_json(text: &str) -> Option<Value> { | |
| 96 | let start = text.find('{')?; | |
| 97 | let end = text.rfind('}')?; | |
| 98 | serde_json::from_str(text.get(start..=end)?).ok() | |
| 99 | } | |
| 100 | ||
| 101 | pub fn main() -> i32 { | |
| 102 | let (Ok(api), Ok(id), Ok(token)) = (env("G1T_API"), env("PLAN_ID"), env("PLAN_TOKEN")) else { | |
| 103 | eprintln!("g1t-runner: G1T_API, PLAN_ID and PLAN_TOKEN must be set"); | |
| 104 | return 2; | |
| 105 | }; | |
| 106 | let secrets: Vec<String> = [ | |
| 107 | "G1T_TOKEN", | |
| 108 | "PLAN_TOKEN", | |
| 109 | "ANTHROPIC_API_KEY", | |
| 110 | "BILLING_TOKEN", | |
| 111 | ] | |
| 112 | .iter() | |
| 113 | .filter_map(|name| std::env::var(name).ok()) | |
| 114 | .filter(|secret| !secret.is_empty()) | |
| 115 | .collect(); | |
| 116 | let redact = |text: String| { | |
| 117 | secrets | |
| 118 | .iter() | |
| 119 | .fold(text, |text, secret| text.replace(secret, "[redacted]")) | |
| 120 | }; | |
| 121 | ||
| 122 | let outcome = plan(); | |
| 123 | let report = match &outcome { | |
| 124 | Ok(plan) => plan.clone(), | |
| 125 | Err(error) => json!({ "error": format!("{error:#}") }), | |
| 126 | }; | |
| 127 | // Whatever the agent wrote passes through here, so nothing it could | |
| 128 | // have read from its environment leaves in a plan. The plan's own token | |
| 129 | // is added afterwards: it is what authorises the report. | |
| 130 | let mut report: Value = | |
| 131 | serde_json::from_str(&redact(report.to_string())).unwrap_or_else(|_| json!({})); | |
| 132 | report["token"] = token.into(); | |
| 133 | if let Err(error) = ureq::post(&format!("{api}/plans/{id}")).send_json(report) { | |
| 134 | eprintln!("g1t-runner: could not report the plan: {error:#}"); | |
| 135 | return 1; | |
| 136 | } | |
| 137 | i32::from(outcome.is_err()) | |
| 138 | } | |
| 139 | ||
| 140 | #[cfg(test)] | |
| 141 | mod tests { | |
| 142 | use super::embedded_json; | |
| 143 | ||
| 144 | #[test] | |
| 145 | fn a_plan_is_found_inside_an_answer() { | |
| 146 | let answer = "Here is the plan:\n```json\n{\"summary\": \"x\", \"issues\": []}\n```\nDone."; | |
| 147 | let plan = embedded_json(answer).unwrap(); | |
| 148 | assert_eq!(plan["summary"], "x"); | |
| 149 | assert!(embedded_json("no plan here").is_none()); | |
| 150 | } | |
| 151 | } |