1use minijinja::Environment;
4use serde::Serialize;
5
6use crate::config::{Config, ReviewConfig, ToolsConfig};
7
8const MANAGED_START: &str = "<!-- edict:managed-start -->";
9const MANAGED_END: &str = "<!-- edict:managed-end -->";
10const MANAGED_START_LEGACY: &str = "<!-- botbox:managed-start -->";
12const MANAGED_END_LEGACY: &str = "<!-- botbox:managed-end -->";
13
14const AGENTS_MANAGED_TEMPLATE: &str = include_str!("templates/agents-managed.md.jinja");
15
16#[derive(Debug, Serialize)]
18pub struct TemplateContext {
19 pub project: ProjectInfo,
21 pub tools: ToolsConfig,
23 pub review: ReviewConfig,
25 pub install_command: Option<String>,
27 pub check_command: Option<String>,
29 pub workflow_docs: Vec<DocEntry>,
31 pub design_docs: Vec<DocEntry>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct ProjectInfo {
37 pub name: String,
38 pub project_type: Vec<String>,
39 pub default_agent: Option<String>,
40 pub channel: Option<String>,
41}
42
43#[derive(Debug, Serialize, Clone)]
44pub struct DocEntry {
45 pub name: String,
46 pub description: String,
47}
48
49impl TemplateContext {
50 pub fn from_config(config: &Config) -> Self {
52 let workflow_docs = list_workflow_docs();
53 let design_docs = list_design_docs(&config.project.project_type);
54
55 Self {
56 project: ProjectInfo {
57 name: config.project.name.clone(),
58 project_type: config.project.project_type.clone(),
59 default_agent: config.project.default_agent.clone(),
60 channel: config.project.channel.clone(),
61 },
62 tools: config.tools.clone(),
63 review: config.review.clone(),
64 install_command: config.project.install_command.clone(),
65 check_command: config.project.check_command.clone(),
66 workflow_docs,
67 design_docs,
68 }
69 }
70}
71
72fn list_workflow_docs() -> Vec<DocEntry> {
74 vec![
75 DocEntry {
76 name: "triage.md".to_string(),
77 description: "Find work from inbox and bones".to_string(),
78 },
79 DocEntry {
80 name: "start.md".to_string(),
81 description: "Claim bone, create workspace, announce".to_string(),
82 },
83 DocEntry {
84 name: "update.md".to_string(),
85 description: "Change bone state (open/doing/done)".to_string(),
86 },
87 DocEntry {
88 name: "finish.md".to_string(),
89 description: "Close bone, merge workspace, release claims".to_string(),
90 },
91 DocEntry {
92 name: "worker-loop.md".to_string(),
93 description: "Full triage-work-finish lifecycle".to_string(),
94 },
95 DocEntry {
96 name: "planning.md".to_string(),
97 description: "Turn specs/PRDs into actionable bones".to_string(),
98 },
99 DocEntry {
100 name: "scout.md".to_string(),
101 description: "Explore unfamiliar code before planning".to_string(),
102 },
103 DocEntry {
104 name: "proposal.md".to_string(),
105 description: "Create and validate proposals before implementation".to_string(),
106 },
107 DocEntry {
108 name: "review-request.md".to_string(),
109 description: "Request a review".to_string(),
110 },
111 DocEntry {
112 name: "review-response.md".to_string(),
113 description: "Handle reviewer feedback (fix/address/defer)".to_string(),
114 },
115 DocEntry {
116 name: "review-loop.md".to_string(),
117 description: "Reviewer agent loop".to_string(),
118 },
119 DocEntry {
120 name: "merge-check.md".to_string(),
121 description: "Merge a worker workspace (protocol merge + conflict recovery)"
122 .to_string(),
123 },
124 DocEntry {
125 name: "preflight.md".to_string(),
126 description: "Validate toolchain health".to_string(),
127 },
128 DocEntry {
129 name: "cross-channel.md".to_string(),
130 description: "Ask questions, report bugs, and track responses across projects"
131 .to_string(),
132 },
133 DocEntry {
134 name: "report-issue.md".to_string(),
135 description: "Report bugs/features to other projects".to_string(),
136 },
137 DocEntry {
138 name: "groom.md".to_string(),
139 description: "groom".to_string(),
140 },
141 ]
142}
143
144fn list_design_docs(project_types: &[String]) -> Vec<DocEntry> {
146 let mut docs = Vec::new();
147
148 for _project_type in project_types {
150 docs.push(DocEntry {
151 name: "cli-conventions.md".to_string(),
152 description: "CLI tool design for humans, agents, and machines".to_string(),
153 });
154 break; }
156
157 docs
158}
159
160pub fn render_managed_section(ctx: &TemplateContext) -> anyhow::Result<String> {
162 let mut env = Environment::new();
163 env.add_template("agents-managed", AGENTS_MANAGED_TEMPLATE)?;
164
165 let template = env.get_template("agents-managed")?;
166 let rendered = template.render(ctx)?;
167
168 Ok(rendered)
169}
170
171pub fn render_agents_md(config: &Config) -> anyhow::Result<String> {
173 let ctx = TemplateContext::from_config(config);
174
175 let tool_list = config
176 .tools
177 .enabled_tools()
178 .into_iter()
179 .map(|t| format!("`{}`", t))
180 .collect::<Vec<_>>()
181 .join(", ");
182
183 let reviewer_line = if config.review.reviewers.is_empty() {
184 String::new()
185 } else {
186 format!("\nReviewer roles: {}", config.review.reviewers.join(", "))
187 };
188
189 let managed = render_managed_section(&ctx)?;
190
191 Ok(format!(
192 "# {}\n\nProject type: {}\nTools: {}{}\n\n<!-- Add project-specific context below: architecture, conventions, key files, etc. -->\n\n{}{}\n{}\n",
193 config.project.name,
194 config.project.project_type.join(", "),
195 tool_list,
196 reviewer_line,
197 MANAGED_START,
198 managed,
199 MANAGED_END
200 ))
201}
202
203pub fn update_managed_section(content: &str, ctx: &TemplateContext) -> anyhow::Result<String> {
209 let managed = render_managed_section(ctx)?;
210 let full_managed = format!("{}\n{}\n{}", MANAGED_START, managed, MANAGED_END);
211
212 if let Some(start_idx) = content.find(MANAGED_START)
214 && let Some(end_idx) = content.find(MANAGED_END)
215 && end_idx > start_idx
216 {
217 let before = &content[..start_idx];
218 let after = &content[end_idx + MANAGED_END.len()..];
219 return Ok(format!("{}{}{}", before, full_managed, after));
220 }
221
222 if let Some(start_idx) = content.find(MANAGED_START_LEGACY)
224 && let Some(end_idx) = content.find(MANAGED_END_LEGACY)
225 && end_idx > start_idx
226 {
227 let before = &content[..start_idx];
228 let after = &content[end_idx + MANAGED_END_LEGACY.len()..];
229 return Ok(format!("{}{}{}", before, full_managed, after));
230 }
231
232 let temp = content
234 .replace(MANAGED_START, "")
235 .replace(MANAGED_END, "")
236 .replace(MANAGED_START_LEGACY, "")
237 .replace(MANAGED_END_LEGACY, "");
238 let cleaned = temp.trim_end();
239 Ok(format!("{}\n\n{}\n", cleaned, full_managed))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_render_agents_md() {
248 let config = Config {
249 version: "1.0.0".to_string(),
250 project: crate::config::ProjectConfig {
251 name: "test-project".to_string(),
252 project_type: vec!["cli".to_string()],
253 default_agent: Some("test-dev".to_string()),
254 channel: Some("test".to_string()),
255 install_command: Some("just install".to_string()),
256 check_command: Some("true".to_string()),
257 languages: vec![],
258 critical_approvers: None,
259 },
260 tools: ToolsConfig {
261 bones: true,
262 maw: true,
263 crit: true,
264 botbus: true,
265 vessel: true,
266 },
267 review: ReviewConfig {
268 enabled: true,
269 reviewers: vec!["security".to_string()],
270 },
271 push_main: false,
272 agents: Default::default(),
273 models: Default::default(),
274 env: Default::default(),
275 };
276
277 let result = render_agents_md(&config).unwrap();
278
279 assert!(result.contains("# test-project"));
280 assert!(result.contains("Tools: `bones`, `maw`, `crit`, `botbus`, `vessel`"));
281 assert!(result.contains("Reviewer roles: security"));
282 assert!(result.contains(MANAGED_START));
283 assert!(result.contains(MANAGED_END));
284 assert!(result.contains("## Edict Workflow"));
285 }
286
287 #[test]
288 fn test_update_managed_section() {
289 let original = r#"# My Project
290
291Some custom content.
292
293<!-- edict:managed-start -->
294Old managed content here
295<!-- edict:managed-end -->
296
297More custom content.
298"#;
299
300 let config = Config {
301 version: "1.0.0".to_string(),
302 project: crate::config::ProjectConfig {
303 name: "test".to_string(),
304 project_type: vec!["cli".to_string()],
305 default_agent: None,
306 channel: None,
307 install_command: None,
308 check_command: None,
309 languages: vec![],
310 critical_approvers: None,
311 },
312 tools: ToolsConfig {
313 bones: true,
314 maw: false,
315 crit: false,
316 botbus: false,
317 vessel: false,
318 },
319 review: ReviewConfig {
320 enabled: false,
321 reviewers: vec![],
322 },
323 push_main: false,
324 agents: Default::default(),
325 models: Default::default(),
326 env: Default::default(),
327 };
328
329 let ctx = TemplateContext::from_config(&config);
330 let result = update_managed_section(original, &ctx).unwrap();
331
332 assert!(result.contains("# My Project"));
333 assert!(result.contains("Some custom content."));
334 assert!(result.contains("More custom content."));
335 assert!(result.contains(MANAGED_START));
336 assert!(result.contains(MANAGED_END));
337 assert!(!result.contains("Old managed content"));
338 assert!(result.contains("## Edict Workflow"));
339 }
340
341 #[test]
342 fn test_update_managed_section_migrates_legacy_markers() {
343 let original = r#"# My Project
345
346Custom content.
347
348<!-- botbox:managed-start -->
349Old botbox-era managed content
350<!-- botbox:managed-end -->
351"#;
352
353 let config = Config {
354 version: "1.0.0".to_string(),
355 project: crate::config::ProjectConfig {
356 name: "test".to_string(),
357 project_type: vec!["cli".to_string()],
358 default_agent: None,
359 channel: None,
360 install_command: None,
361 check_command: None,
362 languages: vec![],
363 critical_approvers: None,
364 },
365 tools: ToolsConfig {
366 bones: true,
367 maw: false,
368 crit: false,
369 botbus: false,
370 vessel: false,
371 },
372 review: ReviewConfig {
373 enabled: false,
374 reviewers: vec![],
375 },
376 push_main: false,
377 agents: Default::default(),
378 models: Default::default(),
379 env: Default::default(),
380 };
381
382 let ctx = TemplateContext::from_config(&config);
383 let result = update_managed_section(original, &ctx).unwrap();
384
385 assert!(result.contains("# My Project"));
386 assert!(result.contains("Custom content."));
387 assert!(!result.contains("botbox:managed-start"));
389 assert!(!result.contains("botbox:managed-end"));
390 assert!(!result.contains("Old botbox-era managed content"));
391 assert!(result.contains(MANAGED_START));
393 assert!(result.contains(MANAGED_END));
394 }
395}