1use std::io::ErrorKind;
14use std::path::Path;
15use std::sync::{Mutex, MutexGuard, PoisonError};
16
17use crate::profile::HarnessKind;
18
19pub const HEADING: &str = "## QCode workspace";
25
26pub const BEGIN: &str = "<!-- qcode:begin -->";
28
29pub const END: &str = "<!-- qcode:end -->";
31
32pub const SECTION: &str = "\
37You are one of several coding agents open as tabs of one QCode workspace, and all of them work in the same folder, `/work`.
38- `list_tabs`, a tool of the MCP server `qcode`, says which tab you are (`you`: your id, title and workspace; its first line says it too) and names the other agent tabs of this workspace: the id to send to, the title, the coding tool, the profile, whether it reaches the network, and how many messages still wait to go into it. Ids count within this workspace; only `list_tabs` tells you yours.
39- `send_message` with `tab` (that id, or `all` for every other agent tab), `text` and `kind` hands them a message. `kind` is `info` (for their information, no answer needed; the default), `question` (you wait for their answer) or `report` (a task: you wait for a report of the result when they finish). If the person turned asking on, the first message from this tab to another waits until they allow it. A message is typed into that tab's prompt once nobody is typing there and its tool has stopped writing. The answer says whether it went in, still waits, or was refused, and why, tab by tab for `all`; `list_tabs` says later whether it arrived.
40- Write the message for an agent that has not seen your conversation. Share files through `/work`: put anything longer than 16000 characters in a file there and send its path.
41- A message from another tab arrives in your own prompt under two lines, in English \"Through QCode, from tab <id> «<title>» (<coding tool>) of this workspace: <kind>\" and how to answer. Answer a `question` with `send_message` to that tab with kind `info`; when you finish a `report` task, send its result the same way.
42- A window tab (Antigravity IDE) has no prompt. A message sent to it waits until its agent calls `check_inbox`, another tool of `qcode`, which hands over every message waiting for that tab with its sender's id and kind. If you are the agent in a window, call `check_inbox` at the start of every task and again when you finish one, and answer each message with `send_message`. Any agent may call it to take messages that wait for its own tab.
43- QCode refuses a message from a tab without the network to a tab with it, ends an exchange between tabs after 6 messages, and lets a tab send at most 5 messages a minute; a message to `all` counts once.
44";
45
46const ANTIGRAVITY_RULE: &str = "\
49---
50trigger: always_on
51description: How to reach the other agent tabs of this QCode workspace.
52---
53
54";
55
56#[must_use]
61pub fn file(harness: HarnessKind) -> &'static str {
62 match harness {
63 HarnessKind::ClaudeCode => "CLAUDE.md",
64 HarnessKind::OpenCode | HarnessKind::Codex | HarnessKind::KimiCode | HarnessKind::QwenCode => "AGENTS.md",
65 HarnessKind::GeminiCli => "GEMINI.md",
66 HarnessKind::AntigravityIde => ".agents/rules/qcode.md",
67 }
68}
69
70#[must_use]
74pub fn platform(harness: HarnessKind) -> &'static str {
75 match harness {
76 HarnessKind::ClaudeCode => "claude",
77 HarnessKind::OpenCode => "opencode",
78 HarnessKind::GeminiCli => "gemini",
79 HarnessKind::Codex => "codex",
80 HarnessKind::KimiCode | HarnessKind::QwenCode => "agents",
88 HarnessKind::AntigravityIde => "antigravity",
89 }
90}
91
92#[must_use]
95pub fn skill(harness: HarnessKind) -> &'static str {
96 match harness {
97 HarnessKind::ClaudeCode => ".claude/skills/graphify/SKILL.md",
98 HarnessKind::OpenCode => ".config/opencode/skills/graphify/SKILL.md",
99 HarnessKind::GeminiCli => ".gemini/skills/graphify/SKILL.md",
100 HarnessKind::Codex => ".codex/skills/graphify/SKILL.md",
101 HarnessKind::KimiCode | HarnessKind::QwenCode => ".agents/skills/graphify/SKILL.md",
102 HarnessKind::AntigravityIde => ".gemini/config/skills/graphify/SKILL.md",
103 }
104}
105
106#[must_use]
109pub fn graphify_file(harness: HarnessKind) -> &'static str {
110 match harness {
111 HarnessKind::ClaudeCode => "CLAUDE.md",
112 HarnessKind::GeminiCli => "GEMINI.md",
113 HarnessKind::OpenCode | HarnessKind::Codex | HarnessKind::KimiCode | HarnessKind::QwenCode => "AGENTS.md",
114 HarnessKind::AntigravityIde => ".agents/rules/graphify.md",
115 }
116}
117
118#[must_use]
130pub fn user_file(harness: HarnessKind) -> &'static str {
131 match harness {
132 HarnessKind::ClaudeCode => ".claude/CLAUDE.md",
133 HarnessKind::OpenCode => ".config/opencode/AGENTS.md",
134 HarnessKind::GeminiCli => ".gemini/GEMINI.md",
135 HarnessKind::Codex => ".codex/AGENTS.md",
136 HarnessKind::KimiCode => ".kimi-code/AGENTS.md",
137 HarnessKind::QwenCode => ".qwen/QWEN.md",
138 HarnessKind::AntigravityIde => ".gemini/config/rules/graphify.md",
139 }
140}
141
142#[must_use]
156pub fn graphify_leftovers(harness: HarnessKind) -> &'static [(&'static str, &'static str)] {
157 match harness {
158 HarnessKind::OpenCode => {
159 &[(".opencode/plugins/graphify.js", ".config/opencode/plugins/graphify.js"), (".opencode", "")]
160 }
161 HarnessKind::AntigravityIde => &[(".agents", "")],
163 HarnessKind::ClaudeCode
164 | HarnessKind::GeminiCli
165 | HarnessKind::Codex
166 | HarnessKind::KimiCode
167 | HarnessKind::QwenCode => &[],
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum Unguided {
175 Graphify(String),
177 Unwritten(String, String),
180 Broken(String),
183}
184
185#[must_use]
187pub fn block() -> String {
188 format!("{HEADING}\n{BEGIN}\n{SECTION}{END}")
189}
190
191pub fn merge(existing: Option<&str>, harness: HarnessKind) -> Result<Option<String>, Unguided> {
204 let inner = format!("{BEGIN}\n{SECTION}{END}");
205 let Some(text) = existing else {
206 let opening = if harness == HarnessKind::AntigravityIde { ANTIGRAVITY_RULE } else { "" };
207 return Ok(Some(format!("{opening}{}\n", block())));
208 };
209 let broken = || Unguided::Broken(file(harness).to_owned());
210 let merged = match text.find(BEGIN) {
211 Some(start) => {
212 let length = text[start..].find(END).ok_or_else(broken)?;
213 let end = start + length + END.len();
214 let before = &text[..start];
215 let ours = if before.ends_with(&format!("{HEADING}\n")) { inner } else { block() };
216 format!("{before}{ours}{}", &text[end..])
217 }
218 None if text.contains(END) => return Err(broken()),
219 None => {
220 let gap = if text.is_empty() || text.ends_with("\n\n") {
221 ""
222 } else if text.ends_with('\n') {
223 "\n"
224 } else {
225 "\n\n"
226 };
227 format!("{text}{gap}{}\n", block())
228 }
229 };
230 Ok((merged != text).then_some(merged))
231}
232
233pub fn write(code: &Path, harness: HarnessKind) -> Result<bool, Unguided> {
244 let name = file(harness);
245 let path = code.join(name);
246 let unwritten = |error: std::io::Error| Unguided::Unwritten(name.to_owned(), error.to_string());
247 let existing = match std::fs::read_to_string(&path) {
248 Ok(text) => Some(text),
249 Err(error) if error.kind() == ErrorKind::NotFound => None,
250 Err(error) => return Err(unwritten(error)),
251 };
252 let Some(text) = merge(existing.as_deref(), harness)? else { return Ok(false) };
253 if let Some(parent) = path.parent() {
254 std::fs::create_dir_all(parent).map_err(unwritten)?;
255 }
256 let file_name = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
257 let beside = path.with_file_name(format!(".{file_name}.qcode-{}", std::process::id()));
258 let written = std::fs::write(&beside, &text)
259 .and_then(|()| match std::fs::metadata(&path) {
260 Ok(old) => std::fs::set_permissions(&beside, old.permissions()),
261 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
262 Err(error) => Err(error),
263 })
264 .and_then(|()| std::fs::rename(&beside, &path));
265 if let Err(error) = written {
266 let _ = std::fs::remove_file(&beside);
267 return Err(unwritten(error));
268 }
269 Ok(true)
270}
271
272pub fn lock() -> MutexGuard<'static, ()> {
278 static WRITING: Mutex<()> = Mutex::new(());
279 WRITING.lock().unwrap_or_else(PoisonError::into_inner)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 const HARNESSES: [HarnessKind; 7] = HarnessKind::ALL;
287
288 const GRAPHIFY: &str = "## graphify\n\nThis project has a knowledge graph at graphify-out/ with god nodes, \
290 community structure, and cross-file relationships.\n\nRules:\n- After modifying \
291 code, run `graphify update .` to keep the graph current (AST-only, no API cost).\n";
292
293 fn ours(text: &str) -> &str {
294 let start = text.find(BEGIN).expect("the section is there");
295 let end = text.find(END).expect("and ends") + END.len();
296 &text[start..end]
297 }
298
299 #[test]
300 fn each_harness_gets_the_file_it_reads() {
301 assert_eq!(file(HarnessKind::ClaudeCode), "CLAUDE.md");
302 assert_eq!(file(HarnessKind::OpenCode), "AGENTS.md");
303 assert_eq!(file(HarnessKind::Codex), "AGENTS.md");
304 assert_eq!(file(HarnessKind::GeminiCli), "GEMINI.md");
305 assert_eq!(file(HarnessKind::KimiCode), "AGENTS.md");
306 assert_eq!(file(HarnessKind::QwenCode), "AGENTS.md");
307 assert_eq!(file(HarnessKind::AntigravityIde), ".agents/rules/qcode.md");
308 assert_ne!(file(HarnessKind::AntigravityIde), ".agents/rules/graphify.md");
310 }
311
312 #[test]
313 fn each_harness_is_known_to_graphify_by_its_own_word() {
314 let words: Vec<&str> = HARNESSES.iter().map(|harness| platform(*harness)).collect();
315 assert_eq!(words, ["claude", "opencode", "gemini", "codex", "agents", "agents", "antigravity"]);
316 }
317
318 #[test]
319 fn a_missing_file_is_made_with_the_section_alone() {
320 let made = merge(None, HarnessKind::ClaudeCode).expect("mergeable").expect("written");
321 assert_eq!(made, format!("{HEADING}\n{BEGIN}\n{SECTION}{END}\n"));
322 assert_eq!(made, format!("{}\n", block()));
323 }
324
325 #[test]
326 fn antigravitys_file_is_made_as_a_rule_it_always_loads() {
327 let made = merge(None, HarnessKind::AntigravityIde).expect("mergeable").expect("written");
328 assert!(made.starts_with("---\ntrigger: always_on\n"), "{made}");
329 assert!(made.ends_with(&format!("{}\n", block())), "{made}");
330 }
331
332 #[test]
333 fn the_persons_text_and_graphifys_section_are_kept_byte_for_byte() {
334 let before = format!("# My project\n\nKeep tabs, not spaces.\n\n{GRAPHIFY}");
335 let after = merge(Some(&before), HarnessKind::ClaudeCode).expect("mergeable").expect("written");
336 assert!(after.starts_with(&before), "{after}");
337 assert_eq!(after, format!("{before}\n{}\n", block()));
338 }
339
340 #[test]
341 fn a_file_without_a_final_line_break_is_not_run_into() {
342 let after = merge(Some("# Mine"), HarnessKind::OpenCode).expect("mergeable").expect("written");
343 assert!(after.starts_with(&format!("# Mine\n\n{HEADING}\n{BEGIN}\n")), "{after}");
344 }
345
346 #[test]
347 fn an_older_section_is_replaced_where_it_stands() {
348 let before = format!("# Mine\n\n{HEADING}\n{BEGIN}\nwhat an older QCode said\n{END}\n\n{GRAPHIFY}");
349 let after = merge(Some(&before), HarnessKind::ClaudeCode).expect("mergeable").expect("written");
350 assert_eq!(after, format!("# Mine\n\n{}\n\n{GRAPHIFY}", block()), "only between the markers");
351 assert!(!after.contains("what an older QCode said"), "{after}");
352 assert_eq!(ours(&after), format!("{BEGIN}\n{SECTION}{END}"));
353 }
354
355 #[test]
356 fn a_section_that_lost_its_heading_gets_it_back_right_before_the_opening_line() {
357 let before = format!("{GRAPHIFY}\n{BEGIN}\n{SECTION}{END}\n");
358 let after = merge(Some(&before), HarnessKind::ClaudeCode).expect("mergeable").expect("written");
359 assert_eq!(after, format!("{GRAPHIFY}\n{}\n", block()));
360 }
361
362 #[test]
363 fn the_same_section_is_not_written_again() {
364 let once = merge(Some(&format!("# Mine\n\n{GRAPHIFY}")), HarnessKind::ClaudeCode)
365 .expect("mergeable")
366 .expect("written");
367 assert_eq!(merge(Some(&once), HarnessKind::ClaudeCode), Ok(None));
368 }
369
370 #[test]
371 fn the_markers_are_never_doubled() {
372 let mut text = merge(None, HarnessKind::GeminiCli).expect("mergeable").expect("written");
373 text.push_str("\nThe person's line after it.\n");
374 let again = merge(Some(&text), HarnessKind::GeminiCli).expect("mergeable");
375 assert_eq!(again, None, "nothing to change");
376 let older = text.replace(SECTION, "older\n");
377 let merged = merge(Some(&older), HarnessKind::GeminiCli).expect("mergeable").expect("written");
378 for line in [HEADING, BEGIN, END] {
379 assert_eq!(merged.matches(line).count(), 1, "{line}: {merged}");
380 }
381 assert!(merged.ends_with("\nThe person's line after it.\n"), "{merged}");
382 }
383
384 #[test]
385 fn one_marker_line_without_the_other_leaves_the_file_alone() {
386 let opening = format!("# Mine\n\n{BEGIN}\nhalf of it\n\nThe person's text.\n");
387 assert_eq!(merge(Some(&opening), HarnessKind::Codex), Err(Unguided::Broken("AGENTS.md".to_owned())));
388 let closing = format!("# Mine\n\nhalf of it\n{END}\n\nThe person's text.\n");
389 assert_eq!(merge(Some(&closing), HarnessKind::Codex), Err(Unguided::Broken("AGENTS.md".to_owned())));
390 }
391
392 fn graphify_installs(text: &str) -> String {
397 let lines: Vec<&str> = text.split('\n').collect();
398 let section = GRAPHIFY.trim();
399 let Some(start) = lines.iter().rposition(|line| line.trim() == "## graphify") else {
400 return if text.trim().is_empty() {
401 format!("{section}\n")
402 } else {
403 format!("{}\n\n{section}\n", text.trim_end())
404 };
405 };
406 let end = (start + 1..lines.len()).find(|&j| lines[j].starts_with("## ")).unwrap_or(lines.len());
407 let head = lines[..start].join("\n");
408 let tail = lines[end..].join("\n");
409 let parts: Vec<&str> =
410 [head.trim_end(), section, tail.trim_start()].into_iter().filter(|part| !part.is_empty()).collect();
411 let out = parts.join("\n\n");
412 if out.ends_with('\n') { out } else { format!("{out}\n") }
413 }
414
415 #[test]
416 fn graphify_installing_again_after_qcode_keeps_qcodes_section_whole() {
417 for start in [None, Some("# Firefly\n\nThe person's own line.\n")] {
419 let first = graphify_installs(start.unwrap_or(""));
420 let ours = merge(Some(&first), HarnessKind::ClaudeCode).expect("mergeable").expect("written");
421 let second = graphify_installs(&ours);
422 assert!(second.contains(&block()), "graphify kept QCode's section whole:\n{second}");
423 assert_eq!(merge(Some(&second), HarnessKind::ClaudeCode), Ok(None), "and QCode has nothing to add");
424 assert_eq!(second, ours, "nothing moved at all");
425 }
426 }
427
428 #[test]
429 fn the_section_says_nothing_graphify_says() {
430 assert!(!SECTION.contains("graphify"), "graphify writes its own section");
431 assert!(SECTION.lines().count() < 15, "short: {} lines", SECTION.lines().count());
432 }
433
434 #[test]
435 fn the_section_names_the_bridges_tools_their_arguments_and_limits_as_the_code_has_them() {
436 use crate::bridge::protocol::MOST_TEXT;
437 use crate::bridge::rules::{MOST_HOPS, MOST_PER_WINDOW, RATE_WINDOW};
438 for word in [
439 "`list_tabs`",
440 "`send_message`",
441 "`check_inbox`",
442 "`tab`",
443 "`text`",
444 "`kind`",
445 "`info`",
446 "`question`",
447 "`report`",
448 "`all`",
449 "`you`",
450 "`qcode`",
451 "`/work`",
452 ] {
453 assert!(SECTION.contains(word), "{word}");
454 }
455 assert!(SECTION.contains(&format!("longer than {MOST_TEXT} characters")), "{SECTION}");
456 assert!(SECTION.contains(&format!("after {MOST_HOPS} messages")), "{SECTION}");
457 assert_eq!(RATE_WINDOW.as_secs(), 60, "the section says a minute");
458 assert!(SECTION.contains(&format!("at most {MOST_PER_WINDOW} messages a minute")), "{SECTION}");
459 assert!(SECTION.contains(&format!("MCP server `{}`", crate::bridge::SERVER_NAME)), "{SECTION}");
460 }
461
462 #[test]
465 fn the_section_shows_the_line_a_delivered_message_starts_with() {
466 let english = include_str!("../../assets/locales/en.toml");
467 let handed =
468 english.lines().find_map(|line| line.strip_prefix("handed = \"")).expect("how a message is handed");
469 assert!(handed.starts_with("{header}\\n"), "the header comes first: {handed}");
470 let header = english
471 .lines()
472 .find_map(|line| line.strip_prefix("header = \""))
473 .and_then(|line| line.strip_suffix('"'))
474 .expect("the English line a delivered message starts with");
475 let shown = header
476 .replace("{tab}", "<id>")
477 .replace("{title}", "<title>")
478 .replace("{harness}", "<coding tool>")
479 .replace("{kind}", "<kind>");
480 assert!(SECTION.contains(&shown), "{shown}");
481 }
482
483 #[test]
484 fn writing_makes_keeps_and_leaves_the_file_on_disk() {
485 let folder = std::env::temp_dir().join(format!("qcode-guidance-{}", std::process::id()));
486 let _ = std::fs::remove_dir_all(&folder);
487 std::fs::create_dir_all(&folder).expect("a folder");
488 assert_eq!(write(&folder, HarnessKind::AntigravityIde), Ok(true));
489 let rule = std::fs::read_to_string(folder.join(".agents/rules/qcode.md")).expect("made");
490 assert!(rule.contains(SECTION));
491 std::fs::write(folder.join("CLAUDE.md"), "# Mine\n").expect("the person's file");
492 assert_eq!(write(&folder, HarnessKind::ClaudeCode), Ok(true));
493 let modified = std::fs::metadata(folder.join("CLAUDE.md")).and_then(|meta| meta.modified()).expect("a time");
494 assert_eq!(write(&folder, HarnessKind::ClaudeCode), Ok(false), "the same content is not written");
495 let again = std::fs::metadata(folder.join("CLAUDE.md")).and_then(|meta| meta.modified()).expect("a time");
496 assert_eq!(modified, again);
497 let text = std::fs::read_to_string(folder.join("CLAUDE.md")).expect("read");
498 assert!(text.starts_with("# Mine\n\n"), "{text}");
499 let leftovers: Vec<_> = std::fs::read_dir(&folder)
500 .expect("listed")
501 .filter_map(Result::ok)
502 .filter(|entry| entry.file_name().to_string_lossy().contains(".qcode-"))
503 .collect();
504 assert!(leftovers.is_empty(), "nothing is left beside the file");
505 let _ = std::fs::remove_dir_all(&folder);
506 }
507}