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

151 lines6,069 bytesCodeBlame
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
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, git, 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- Give acceptance checks: shell commands that must pass once the change is made. Use the project's real test or build commands. \
38Give 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, \
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 \"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; \
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 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.
95fn 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
101pub 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)]
141mod 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}