Inline untracked @file references in DOT prompts at prepare time

Cloud sandboxes clone the repo, so untracked local files referenced via
prompt="@path/to/file.md" won't exist. This inlines those file contents
at prepare time while leaving git-tracked @references for the agent to
read from the sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 21:32:53 -05:00
parent ecd5d0ae9a
commit d56ed52490
3 changed files with 322 additions and 1 deletions

View file

@ -242,9 +242,25 @@ pub async fn run_command(
Some(vars) => run_config::expand_vars(&source, vars)?,
None => source,
};
let (mut graph, diagnostics) = WorkflowBuilder::new().prepare(&source)?;
let dot_dir = dot_path.parent().unwrap_or(std::path::Path::new("."));
let mut builder = WorkflowBuilder::new();
builder.register_transform(Box::new(
crate::transform::FileInliningTransform::new(dot_dir.to_path_buf()),
));
let (mut graph, diagnostics) = builder.prepare(&source)?;
apply_goal_override(&mut graph, args.goal.as_deref());
// Inline @file references in the (possibly overridden) goal
if let Some(crate::graph::types::AttrValue::String(goal)) = graph.attrs.get("goal") {
let resolved = crate::transform::resolve_file_ref(goal, dot_dir);
if resolved != *goal {
graph.attrs.insert(
"goal".to_string(),
crate::graph::types::AttrValue::String(resolved),
);
}
}
eprintln!(
"{} {} {}",
styles.bold.apply_to("Workflow:"),

View file

@ -291,6 +291,18 @@ pub fn push_ref(repo: &Path, url: &str, refname: &str) -> Result<()> {
Ok(())
}
/// Check whether a file is tracked by git in the given repo.
/// Returns `false` if the file is untracked or git is unavailable.
pub fn is_tracked(repo: &Path, file: &Path) -> bool {
git_cmd(repo)
.args(["ls-files", "--error-unmatch"])
.arg(file)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
/// Sanitize a string for use as a git ref component.
/// Lowercases, replaces non-alphanumeric chars with dashes, collapses runs.
pub fn sanitize_ref_component(s: &str) -> String {
@ -1100,4 +1112,44 @@ mod tests {
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("test-push"), "remote should have test-push branch");
}
#[test]
fn is_tracked_returns_true_for_committed_file() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let file = dir.path().join("tracked.txt");
fs::write(&file, "hello").unwrap();
Command::new("git")
.args(["add", "tracked.txt"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add file",
])
.current_dir(dir.path())
.output()
.unwrap();
assert!(is_tracked(dir.path(), &file));
}
#[test]
fn is_tracked_returns_false_for_untracked_file() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let file = dir.path().join("untracked.txt");
fs::write(&file, "hello").unwrap();
assert!(!is_tracked(dir.path(), &file));
}
#[test]
fn is_tracked_returns_false_for_non_repo_dir() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("some.txt");
fs::write(&file, "hello").unwrap();
assert!(!is_tracked(dir.path(), &file));
}
}

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::graph::{AttrValue, Edge, Graph, Node};
use crate::stylesheet::{apply_stylesheet, parse_stylesheet};
@ -111,6 +112,83 @@ impl Transform for StylesheetApplicationTransform {
}
}
/// Resolve a potential `@path` file reference.
///
/// If `value` starts with `@`, the referenced file exists locally, and is NOT
/// tracked by git, the file contents are returned (inlined). Otherwise the
/// original value is returned unchanged.
pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String {
let path_str = match value.strip_prefix('@') {
Some(p) => p,
None => return value.to_string(),
};
let file_path = base_dir.join(path_str);
if !file_path.is_file() {
return value.to_string();
}
// Discover repo root from base_dir
let repo_root = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(base_dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()));
if let Some(root) = repo_root {
if crate::git::is_tracked(&root, &file_path) {
return value.to_string();
}
}
match std::fs::read_to_string(&file_path) {
Ok(contents) => contents,
Err(e) => {
tracing::warn!(path = %file_path.display(), error = %e, "Failed to read @file reference");
value.to_string()
}
}
}
/// Inlines untracked `@file` references in node prompts and the graph-level goal.
pub struct FileInliningTransform {
base_dir: PathBuf,
}
impl FileInliningTransform {
#[must_use]
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
}
impl Transform for FileInliningTransform {
fn apply(&self, graph: &mut Graph) {
// Inline @file refs in node prompts
for node in graph.nodes.values_mut() {
if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") {
let resolved = resolve_file_ref(prompt, &self.base_dir);
if resolved != *prompt {
node.attrs
.insert("prompt".to_string(), AttrValue::String(resolved));
}
}
}
// Inline @file refs in graph-level goal
if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") {
let resolved = resolve_file_ref(goal, &self.base_dir);
if resolved != *goal {
graph
.attrs
.insert("goal".to_string(), AttrValue::String(resolved));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -516,4 +594,179 @@ mod tests {
Some("outcome=success")
);
}
// -----------------------------------------------------------------------
// resolve_file_ref tests
// -----------------------------------------------------------------------
#[test]
fn resolve_file_ref_passthrough_non_at() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(resolve_file_ref("hello world", dir.path()), "hello world");
}
#[test]
fn resolve_file_ref_passthrough_missing_file() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
resolve_file_ref("@nonexistent.md", dir.path()),
"@nonexistent.md"
);
}
#[test]
fn resolve_file_ref_passthrough_tracked_file() {
let dir = tempfile::tempdir().unwrap();
// Init repo and commit a file
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("tracked.md"), "tracked content").unwrap();
std::process::Command::new("git")
.args(["add", "tracked.md"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add",
])
.current_dir(dir.path())
.output()
.unwrap();
assert_eq!(
resolve_file_ref("@tracked.md", dir.path()),
"@tracked.md"
);
}
#[test]
fn resolve_file_ref_inlines_untracked_file() {
let dir = tempfile::tempdir().unwrap();
// Init repo
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("local.md"), "inlined content").unwrap();
assert_eq!(
resolve_file_ref("@local.md", dir.path()),
"inlined content"
);
}
// -----------------------------------------------------------------------
// FileInliningTransform tests
// -----------------------------------------------------------------------
#[test]
fn file_inlining_transform_inlines_prompt_and_goal() {
let dir = tempfile::tempdir().unwrap();
// Init repo
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("prompt.md"), "Do the work").unwrap();
std::fs::write(dir.path().join("goal.md"), "Ship feature").unwrap();
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("@goal.md".to_string()),
);
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("@prompt.md".to_string()),
);
graph.nodes.insert("work".to_string(), node);
let transform = FileInliningTransform::new(dir.path().to_path_buf());
transform.apply(&mut graph);
assert_eq!(
graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Do the work")
);
assert_eq!(
graph.attrs.get("goal").and_then(AttrValue::as_str),
Some("Ship feature")
);
}
#[test]
fn file_inlining_transform_leaves_tracked_files() {
let dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("prompt.md"), "committed content").unwrap();
std::process::Command::new("git")
.args(["add", "prompt.md"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add",
])
.current_dir(dir.path())
.output()
.unwrap();
let mut graph = Graph::new("test");
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("@prompt.md".to_string()),
);
graph.nodes.insert("work".to_string(), node);
let transform = FileInliningTransform::new(dir.path().to_path_buf());
transform.apply(&mut graph);
assert_eq!(
graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("@prompt.md")
);
}
}