flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/plan.rs

147 lines6,079 bytesCodeBlame
1//! Turns an outcome someone wrote into a plan: the issues that would get
2//! there, what done means for each, which files each will touch, and which
3//! must 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
18use std::path::Path;
19
20use anyhow::{Context, Result, bail};
21use serde_json::{Value, json};
22
23use crate::report::Reporter;
24use crate::{WORKDIR, auth_option, env, harness};
25
26const PLAN_FILE: &str = "/work/plan.json";
27
28const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \
29Read enough of it to understand how it is built and tested. Do not modify it.
30
31Split the outcome below into issues. Each issue will be given to a separate agent that sees only that issue and the repository, \
32and 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- Say what done means for each, in plain words a reviewer can confirm: a few short points, such as what a command prints or what a page shows. \
38Do not list the project's tests: its workflows run them on every pull request.
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, \
41make the later one depend on the earlier. Otherwise leave them independent, so that they are worked on at the same time.
42
43Write 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 \"done\": [\"`greet ana` prints Hello, ana!\"],
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; \
60it is empty for an issue that can start at once. An issue may only depend on issues before it. Then finish.
61
62The outcome wanted:";
63
64fn 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 crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR).context("could not clone the repository")?;
71
72 let prompt = format!("{INSTRUCTIONS}\n\n{}", env("PROMPT")?);
73 // A plan has no session; what matters is the plan.
74 let mut reporter = Reporter::silent();
75 let answer = harness::run_claude(workdir, &prompt, &mut reporter)?;
76
77 let written = std::fs::read_to_string(PLAN_FILE).unwrap_or_default();
78 let plan: Value = serde_json::from_str(&written)
79 .ok()
80 .filter(|plan: &Value| plan["issues"].is_array())
81 // The agent may have answered with the plan instead of writing it.
82 .or_else(|| embedded_json(&answer).filter(|plan| plan["issues"].is_array()))
83 .context("the agent did not produce a plan")?;
84 if plan["issues"].as_array().is_some_and(Vec::is_empty) {
85 bail!("the agent proposed no issues");
86 }
87 Ok(plan)
88}
89
90/// The JSON object in a piece of text, from its first brace to its last.
91fn embedded_json(text: &str) -> Option<Value> {
92 let start = text.find('{')?;
93 let end = text.rfind('}')?;
94 serde_json::from_str(text.get(start..=end)?).ok()
95}
96
97pub fn main() -> i32 {
98 let (Ok(api), Ok(id), Ok(token)) = (env("G1T_API"), env("PLAN_ID"), env("PLAN_TOKEN")) else {
99 eprintln!("g1t-runner: G1T_API, PLAN_ID and PLAN_TOKEN must be set");
100 return 2;
101 };
102 let secrets: Vec<String> = [
103 "G1T_TOKEN",
104 "PLAN_TOKEN",
105 "ANTHROPIC_API_KEY",
106 "BILLING_TOKEN",
107 ]
108 .iter()
109 .filter_map(|name| std::env::var(name).ok())
110 .filter(|secret| !secret.is_empty())
111 .collect();
112 let redact = |text: String| {
113 secrets
114 .iter()
115 .fold(text, |text, secret| text.replace(secret, "[redacted]"))
116 };
117
118 let outcome = plan();
119 let report = match &outcome {
120 Ok(plan) => plan.clone(),
121 Err(error) => json!({ "error": format!("{error:#}") }),
122 };
123 // Whatever the agent wrote passes through here, so nothing it could
124 // have read from its environment leaves in a plan. The plan's own token
125 // is added afterwards: it is what authorises the report.
126 let mut report: Value =
127 serde_json::from_str(&redact(report.to_string())).unwrap_or_else(|_| json!({}));
128 report["token"] = token.into();
129 if let Err(error) = ureq::post(&format!("{api}/plans/{id}")).send_json(report) {
130 eprintln!("g1t-runner: could not report the plan: {error:#}");
131 return 1;
132 }
133 i32::from(outcome.is_err())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::embedded_json;
139
140 #[test]
141 fn a_plan_is_found_inside_an_answer() {
142 let answer = "Here is the plan:\n```json\n{\"summary\": \"x\", \"issues\": []}\n```\nDone.";
143 let plan = embedded_json(answer).unwrap();
144 assert_eq!(plan["summary"], "x");
145 assert!(embedded_json("no plan here").is_none());
146 }
147}