Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Agent contract**: verb templates gain a `{session_name}` placeholder, and
the built-in `claude` agent now names its session with it instead of spelling
`voro-{task_id}` itself. Every session Voro launches carries a name it
composed — `voro-<id>` for a dispatch (unchanged), `voro-<id>-refine` for a
refine, `voro-plan-<project>` for a planning session — so a refine no longer
launches under the literal, shared name `voro-{task_id}` and can be found and
attached to in `claude agents`. `{session_name}` is honoured on `dispatch` and
`plan` and refused elsewhere, the same rule `{model}` follows; `{task_id}` is
now refused on `plan`, whose target may be a project with no task. No verb was
added: an agent defining only `dispatch` (or `cmd`) refines exactly as before.
A wholesale `[agents.claude]` override copied from an older `voro.toml` keeps
working, and keeps its old naming.
- Prompts and command lines are filled by a single-pass renderer, so a task
body, branch name, document title or project name containing `{task_id}`,
`{db}`, `{note}`, `{seed}`, `{branch}` or `{docs}` now reaches the agent
verbatim instead of being rewritten before it is read.
- `projects.path` is gone. Existing databases convert in place: every project's
old path becomes its default repo, and existing tasks resolve to exactly the
checkouts they had before. `voro project add` and `voro project path` keep
Expand Down
392 changes: 327 additions & 65 deletions crates/voro-core/src/agent.rs

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions crates/voro-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ mod model;
mod pr;
pub mod scheduler;
mod store;
mod template;
mod transition;

pub use agent::{
AgentSessionEntry, AgentTemplate, AgentsConfig, PROMPT_FILE_PLACEHOLDER, Provenance,
ResolvedAgent, SESSION_PLACEHOLDER, TASK_ID_PLACEHOLDER, VIEWER_BASE_PLACEHOLDER,
VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, ViewerTemplate, parse_sessions_json,
AgentSessionEntry, AgentTemplate, AgentsConfig, Launch, LaunchSpec, PROMPT_FILE_PLACEHOLDER,
Provenance, ResolvedAgent, SESSION_NAME_PLACEHOLDER, SESSION_PLACEHOLDER, TASK_ID_PLACEHOLDER,
VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, ViewerTemplate,
parse_sessions_json,
};
pub use error::{Error, Result};
pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body};
Expand All @@ -30,6 +32,7 @@ pub use scheduler::{
ScoreBreakdown, StateCounts, WipGate,
};
pub use store::{NewTask, Store, TaskEdit};
pub use template::{render, shell_quote};
pub use transition::{Action, Triage};

#[cfg(test)]
Expand Down
103 changes: 103 additions & 0 deletions crates/voro-core/src/template.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! The one substitution routine (DESIGN.md §8). Every template Voro fills —
//! agent command lines, dispatch preambles, planning and refine prompts — goes
//! through [`render`], because chained `String::replace` calls re-scan values
//! they have already emitted: whichever untrusted value goes in last, the ones
//! before it were searched for the placeholders that came after. A task body
//! discussing `{task_id}` is then silently rewritten before the agent sees it.
//!
//! Shell quoting lives here too, so command assembly owns it rather than
//! borrowing it from the TUI crate.

use std::path::Path;

/// Fill `template` from `bindings` in a single left-to-right pass: each bound
/// placeholder's value is emitted verbatim and never re-scanned, so a value
/// containing another placeholder survives whatever order the bindings are
/// given in. An unrecognised `{…}` is copied through untouched, which is what
/// makes composing nested blocks safe — render the inner block first, then bind
/// the finished text.
pub fn render(template: &str, bindings: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
rest = &rest[open..];
// Longest match wins, so one placeholder that prefixes another cannot
// shadow it whichever order the bindings arrive in.
let matched = bindings
.iter()
.filter(|(name, _)| rest.starts_with(name))
.max_by_key(|(name, _)| name.len());
match matched {
Some((name, value)) => {
out.push_str(value);
rest = &rest[name.len()..];
}
None => {
out.push('{');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}

/// Single-quote a path for safe substitution into an `sh -c` command line.
pub fn shell_quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn bound_placeholders_are_filled_and_unknown_ones_copied_through() {
assert_eq!(
render("run {a} then {b} and {c}", &[("{a}", "1"), ("{b}", "2")]),
"run 1 then 2 and {c}"
);
assert_eq!(render("nothing to do", &[("{a}", "1")]), "nothing to do");
assert_eq!(render("{a}{a}{a}", &[("{a}", "x")]), "xxx");
assert_eq!(render("a { b } c", &[("{b}", "x")]), "a { b } c");
}

/// The defect this routine exists for: a value that itself contains a
/// placeholder reaches the output as written, whichever order the bindings
/// are given in. Chained `String::replace` cannot promise that.
#[test]
fn a_value_containing_another_placeholder_is_emitted_verbatim() {
let body = "the note says {task_id} is wrong, and {db} too";
for bindings in [
vec![("{seed}", body), ("{task_id}", "42"), ("{db}", " --db x")],
vec![("{task_id}", "42"), ("{db}", " --db x"), ("{seed}", body)],
] {
assert_eq!(
render("task {task_id}:\n{seed}\n{db}", &bindings),
format!("task 42:\n{body}\n --db x")
);
}
}

#[test]
fn the_longest_matching_placeholder_wins() {
for bindings in [
vec![("{project}", "p"), ("{project_arg}", "'p'")],
vec![("{project_arg}", "'p'"), ("{project}", "p")],
] {
assert_eq!(render("{project} {project_arg}", &bindings), "p 'p'");
}
}

#[test]
fn unicode_before_an_unbound_brace_is_not_split() {
assert_eq!(render("— {x} — {y}", &[("{x}", "ok")]), "— ok — {y}");
}

#[test]
fn shell_quote_wraps_and_escapes() {
assert_eq!(shell_quote(Path::new("/tmp/a b")), "'/tmp/a b'");
assert_eq!(shell_quote(Path::new("/tmp/it's")), "'/tmp/it'\\''s'");
}
}
Loading
Loading