use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use chrono::Local;
use serde::Deserialize;
use crate::guard::{self, Readiness};
use crate::inspect;
use crate::schemas::{
IntentContract, SelectionPolicy, Task, TaskState, WorkQueue, WorkerProfile, WorkersFile,
};
use crate::state::{self, write_str, Workspace};
use crate::{packet, workers, yaml};
#[derive(Debug, Default, Deserialize)]
struct PlanningResult {
#[serde(default)]
summary: String,
#[serde(default)]
allowed_scope: Vec<String>,
#[serde(default)]
out_of_scope: Vec<String>,
#[serde(default)]
acceptance: Vec<PlanAcceptance>,
#[serde(default)]
ambiguity: PlanAmbiguity,
#[serde(default)]
tasks: Vec<PlanTask>,
#[serde(default)]
questions_for_user: Vec<PlanQuestion>,
}
#[derive(Debug, Default, Deserialize)]
struct PlanAmbiguity {
#[serde(default)]
score: String,
#[serde(default)]
open_questions: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct PlanAcceptance {
#[serde(default)]
statement: String,
}
#[derive(Debug, Default, Deserialize)]
struct PlanTask {
#[serde(default)]
id: String,
#[serde(default)]
title: String,
#[serde(default)]
kind: String,
#[serde(default)]
risk: String,
#[serde(default)]
preferred_worker: String,
#[serde(default)]
model: String,
#[serde(default)]
effort: String,
#[serde(default)]
depends_on: Vec<String>,
#[serde(default)]
skills: Vec<String>,
#[serde(default)]
allowed_scope: Vec<String>,
#[serde(default)]
acceptance: Vec<String>,
#[serde(default)]
worker_rationale: Option<String>,
}
fn sanitize_deps(depends_on: &[String], prior_ids: &[String]) -> Vec<String> {
depends_on
.iter()
.filter(|d| prior_ids.iter().any(|p| p == *d))
.cloned()
.collect()
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlanQuestion {
Text(String),
Obj {
#[serde(default)]
question: String,
#[serde(default)]
statement: String,
},
}
impl PlanQuestion {
fn into_text(self) -> String {
match self {
PlanQuestion::Text(s) => s,
PlanQuestion::Obj {
question,
statement,
} => {
if !question.trim().is_empty() {
question
} else {
statement
}
}
}
}
}
#[derive(Debug, Default, serde::Serialize, Deserialize)]
struct PlanMeta {
mode: String, #[serde(default)]
request: String,
}
const CONSUMED_MARKER: &str = "consumed";
fn mark_consumed(run_dir: &std::path::Path) {
let _ = write_str(&run_dir.join(CONSUMED_MARKER), "");
}
fn legacy_plan_meta(run_dir: &std::path::Path) -> Option<PlanMeta> {
let packet = std::fs::read_to_string(workers::packet_path(run_dir)).ok()?;
let request = packet
.split("## Request (verbatim)")
.nth(1)?
.split("\n## ")
.next()
.unwrap_or("")
.trim()
.to_string();
let mode = if request.contains("This is a FOLLOW-UP") {
"amend"
} else {
"new"
};
Some(PlanMeta {
mode: mode.to_string(),
request,
})
}
pub struct PlanningReport {
pub run_id: String,
pub worker_id: String,
pub intent_summary: String,
pub task_count: usize,
pub questions: Vec<String>,
pub lines: Vec<String>,
}
pub fn plan_goal(
ws: &Workspace,
goal: &str,
verify: Option<&str>,
worker_override: Option<&str>,
) -> Result<usize> {
let goal = goal.trim();
if goal.is_empty() {
bail!("describe the goal, e.g. `yardlet goal \"fix the login redirect\"`");
}
let intent_id = format!("intent-{}", Local::now().format("%Y%m%d-%H%M%S"));
let worker = worker_override.unwrap_or("").to_string();
let mut tasks = vec![Task {
id: "YARD-001".to_string(),
title: goal.chars().take(80).collect(),
state: TaskState::Queued,
priority: 10,
risk: "low".to_string(),
kind: "implementation".to_string(),
preferred_worker: worker.clone(),
model: String::new(),
effort: String::new(),
depends_on: vec![],
skills: vec![],
allowed_scope: vec![],
acceptance: vec![yaml::Value::String(goal.to_string())],
validation: None,
approval: None,
interaction: None,
worker_rationale: Some("express goal (yardlet goal)".to_string()),
}];
for task in &mut tasks {
crate::routing::apply_forced_worker(task);
}
if let Some(v) = verify.map(str::trim).filter(|v| !v.is_empty()) {
tasks.push(Task {
id: "YARD-002".to_string(),
title: "Verify the goal".to_string(),
state: TaskState::Queued,
priority: 20,
risk: "low".to_string(),
kind: "review".to_string(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec!["YARD-001".to_string()],
skills: vec![],
allowed_scope: vec![],
acceptance: vec![yaml::Value::String(format!(
"Verify against the actual workspace, with evidence: {v}"
))],
validation: None,
approval: None,
interaction: None,
worker_rationale: Some("verifier is never the doer".to_string()),
});
}
let intent = IntentContract {
schema_version: 1,
id: intent_id.clone(),
source: "user".to_string(),
raw_request: goal.to_string(),
summary: goal.to_string(),
allowed_scope: vec![],
out_of_scope: vec![],
acceptance: verify
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| vec![yaml::Value::String(v.to_string())])
.unwrap_or_default(),
images: vec![],
ambiguity: "low".to_string(),
open_questions: vec![],
clarifications: vec![],
interview_turns: 0,
status: "accepted".to_string(),
};
let queue = WorkQueue {
schema_version: 1,
queue_id: format!("queue-{intent_id}"),
intent_id,
selection_policy: SelectionPolicy::default(),
tasks,
};
let task_count = queue.tasks.len();
let _ = crate::report::archive_intent(ws);
state::save_yaml(&ws.intent_path(), &intent)?;
ws.save_queue(&queue)?;
let _ = crate::skills::auto_equip(ws, &inspect::summarize(&ws.root));
Ok(task_count)
}
pub fn run_planning(
ws: &Workspace,
request: &str,
worker_override: Option<&str>,
explicit_images: &[String],
) -> Result<PlanningReport> {
let workers = ws.load_workers()?;
let billing = ws.load_billing()?;
let config = ws.load_config()?;
let mut images: Vec<String> = explicit_images.to_vec();
for d in packet::detect_images(request, &ws.root) {
if !images.contains(&d) {
images.push(d);
}
}
plan_core(
ws,
&workers,
&billing,
&config,
request,
request,
&images,
worker_override,
"new",
true,
)
}
pub const INTERVIEW_CAP: u32 = 10;
pub fn intent_gated(intent: &IntentContract, gate_enabled: bool) -> bool {
gate_enabled
&& intent.ambiguity == "high"
&& intent.interview_turns < INTERVIEW_CAP
&& !intent.open_questions.is_empty()
}
pub fn run_planning_interview(ws: &Workspace, answer: &str) -> Result<PlanningReport> {
let Some(prev) = ws.load_intent()? else {
bail!("no intent to interview \u{2014} plan first (n)");
};
let queue = ws.load_queue()?;
let workers = ws.load_workers()?;
let billing = ws.load_billing()?;
let config = ws.load_config()?;
let turns = prev.interview_turns + 1;
let mut clarifications = prev.clarifications.clone();
clarifications.push(format!(
"Q: {}\nA: {}",
prev.open_questions.join(" / "),
answer.trim()
));
let mut ctx = String::new();
ctx.push_str(&format!("Original request:\n{}\n\n", prev.raw_request));
ctx.push_str(&format!("Current plan summary: {}\n", prev.summary));
if !queue.tasks.is_empty() {
ctx.push_str("Current planned tasks (revise them freely):\n");
for t in &queue.tasks {
ctx.push_str(&format!("- {} {}\n", t.id, t.title));
}
}
ctx.push_str("\nInterview so far:\n");
for c in &clarifications {
ctx.push_str(c);
ctx.push_str("\n---\n");
}
ctx.push_str(&format!(
"\nThis is interview turn {turns}/{cap}. RE-PLAN the whole intent with these \
answers folded in: revise the summary, scope, acceptance, and tasks as needed. \
Re-score `ambiguity` honestly \u{2014} drop it below \"high\" only when you are no \
longer guessing about product behavior or architecture. If something essential \
is still unclear, ask up to 3 NEW questions (never repeat an answered one).",
cap = INTERVIEW_CAP
));
let report = plan_core(
ws,
&workers,
&billing,
&config,
&ctx,
&prev.raw_request,
&prev.images,
None,
"interview",
false,
)?;
if let Some(mut intent) = ws.load_intent()? {
intent.id = prev.id.clone();
intent.raw_request = prev.raw_request.clone();
intent.clarifications = clarifications;
intent.interview_turns = turns;
state::save_yaml(&ws.intent_path(), &intent)?;
let mut q = ws.load_queue()?;
q.intent_id = prev.id.clone();
q.queue_id = format!("queue-{}", prev.id);
ws.save_queue(&q)?;
}
Ok(report)
}
#[allow(clippy::too_many_arguments)]
fn plan_core(
ws: &Workspace,
workers: &WorkersFile,
billing: &crate::schemas::BillingPolicy,
config: &crate::schemas::YardConfig,
packet_request: &str,
store_request: &str,
images: &[String],
worker_override: Option<&str>,
mode: &str,
archive: bool,
) -> Result<PlanningReport> {
let language = packet::resolve_language(&config.language, store_request);
let (profile, bin, worker_id) = pick_ready_worker(workers, billing, worker_override)?;
let run_id = format!("plan-{}", Local::now().format("%Y%m%d-%H%M%S"));
let run_dir = ws.runs_dir().join(&run_id);
std::fs::create_dir_all(run_dir.join("evidence"))?;
let run_dir_rel = format!(".agents/runs/{run_id}");
state::save_yaml(
&run_dir.join("plan-meta.yaml"),
&PlanMeta {
mode: mode.to_string(),
request: store_request.to_string(),
},
)?;
let mut lines = Vec::new();
let summary = inspect::summarize(&ws.root);
write_str(
&run_dir.join("evidence").join("repo-summary.md"),
&inspect::to_markdown(&summary),
)?;
let equipped = crate::skills::auto_equip(ws, &summary);
if !equipped.is_empty() {
lines.push(format!("equipped skills: {}", equipped.join(", ")));
}
let pruned = crate::skills::auto_prune(ws);
if !pruned.is_empty() {
lines.push(format!("pruned weak skills: {}", pruned.join(", ")));
}
let worker_guidance = build_worker_guidance(workers);
let harness = packet::discover_harness(&ws.root, config.harness_discovery);
let packet_text = packet::compile_planning(
packet_request,
&summary,
&run_dir_rel,
&language,
&worker_guidance,
images,
&harness,
&worker_id,
);
write_str(&workers::packet_path(&run_dir), &packet_text)?;
if archive {
let _ = crate::report::archive_intent(ws);
let _ = ws.save_queue(&WorkQueue {
schema_version: 1,
queue_id: "planning".to_string(),
intent_id: String::new(),
selection_policy: SelectionPolicy::default(),
tasks: Vec::new(),
});
}
let env = guard::sanitized_worker_env_for(billing, &profile.invocation.pass_env)
.map_err(|e| anyhow!(e))?;
let timeout = Duration::from_secs(profile.limits.max_wall_minutes as u64 * 60);
let outcome = workers::spawn(
&profile,
&bin,
&packet_text,
&ws.root,
&env,
&run_dir.join("worker-output.log"),
timeout,
false, images,
None,
false,
)?;
lines.push(format!("worker outcome: {}", outcome.note));
let result_path = run_dir.join("planning-result.json");
let raw = std::fs::read_to_string(&result_path).with_context(|| {
format!(
"planning worker did not write {}. Inspect {}/worker-output.log",
result_path.display(),
run_dir_rel
)
})?;
let plan: PlanningResult =
serde_json::from_str(&raw).with_context(|| format!("parsing {}", result_path.display()))?;
if plan.summary.trim().is_empty() || plan.tasks.is_empty() {
bail!(
"planning produced no usable plan (empty summary or no tasks). See {}",
result_path.display()
);
}
let intent_id = format!("intent-{}", Local::now().format("%Y%m%d-%H%M%S"));
let intent = build_intent(&intent_id, store_request, &plan, images);
let queue = build_queue(&intent_id, &plan);
state::save_yaml(&ws.intent_path(), &intent)?;
ws.save_queue(&queue)?;
mark_consumed(&run_dir);
Ok(PlanningReport {
run_id,
worker_id,
intent_summary: intent.summary,
task_count: queue.tasks.len(),
questions: plan
.questions_for_user
.into_iter()
.map(PlanQuestion::into_text)
.filter(|q| !q.trim().is_empty())
.collect(),
lines,
})
}
pub fn run_planning_amend(ws: &Workspace, request: &str) -> Result<PlanningReport> {
let existing_intent = ws.load_intent()?;
let existing_queue = ws.load_queue()?;
let mut ctx = String::new();
if let Some(i) = &existing_intent {
ctx.push_str(&format!(
"This is a FOLLOW-UP to an existing intent.\nCurrent goal: {}\n\n",
i.summary
));
}
if !existing_queue.tasks.is_empty() {
ctx.push_str("Already-planned tasks (do NOT recreate these):\n");
for t in &existing_queue.tasks {
ctx.push_str(&format!("- {} [{:?}] {}\n", t.id, t.state, t.title));
}
ctx.push('\n');
}
ctx.push_str(&format!(
"Follow-up request from the user:\n{request}\n\nProduce ONLY new tasks that add \
to this work; do not redo the tasks above."
));
let workers = ws.load_workers()?;
let billing = ws.load_billing()?;
let config = ws.load_config()?;
let language = packet::resolve_language(&config.language, &ctx);
let images: Vec<String> = Vec::new();
let (profile, bin, worker_id) = pick_ready_worker(&workers, &billing, None)?;
let run_id = format!("plan-{}", Local::now().format("%Y%m%d-%H%M%S"));
let run_dir = ws.runs_dir().join(&run_id);
std::fs::create_dir_all(run_dir.join("evidence"))?;
let run_dir_rel = format!(".agents/runs/{run_id}");
state::save_yaml(
&run_dir.join("plan-meta.yaml"),
&PlanMeta {
mode: "amend".to_string(),
request: request.to_string(),
},
)?;
let mut lines = Vec::new();
let summary = inspect::summarize(&ws.root);
write_str(
&run_dir.join("evidence").join("repo-summary.md"),
&inspect::to_markdown(&summary),
)?;
let worker_guidance = build_worker_guidance(&workers);
let harness = packet::discover_harness(&ws.root, config.harness_discovery);
let packet_text = packet::compile_planning(
&ctx,
&summary,
&run_dir_rel,
&language,
&worker_guidance,
&images,
&harness,
&worker_id,
);
write_str(&workers::packet_path(&run_dir), &packet_text)?;
let env = guard::sanitized_worker_env_for(&billing, &profile.invocation.pass_env)
.map_err(|e| anyhow!(e))?;
let timeout = Duration::from_secs(profile.limits.max_wall_minutes as u64 * 60);
let outcome = workers::spawn(
&profile,
&bin,
&packet_text,
&ws.root,
&env,
&run_dir.join("worker-output.log"),
timeout,
false,
&images,
None,
false,
)?;
lines.push(format!("worker outcome: {}", outcome.note));
let result_path = run_dir.join("planning-result.json");
let raw = std::fs::read_to_string(&result_path).with_context(|| {
format!(
"planning worker did not write {}. Inspect {}/worker-output.log",
result_path.display(),
run_dir_rel
)
})?;
let plan: PlanningResult =
serde_json::from_str(&raw).with_context(|| format!("parsing {}", result_path.display()))?;
if plan.tasks.is_empty() {
bail!("amend produced no new tasks. See {}", result_path.display());
}
let mut queue = existing_queue;
let added = append_plan_tasks(&mut queue, &plan);
ws.save_queue(&queue)?;
if let Some(mut intent) = existing_intent {
if !plan.summary.trim().is_empty() {
intent.summary = format!("{}\n\n[follow-up] {}", intent.summary, plan.summary.trim());
state::save_yaml(&ws.intent_path(), &intent)?;
}
}
mark_consumed(&run_dir);
Ok(PlanningReport {
run_id,
worker_id,
intent_summary: format!("+{added} task(s)"),
task_count: queue.tasks.len(),
questions: plan
.questions_for_user
.into_iter()
.map(PlanQuestion::into_text)
.filter(|q| !q.trim().is_empty())
.collect(),
lines,
})
}
fn append_plan_tasks(queue: &mut WorkQueue, plan: &PlanningResult) -> usize {
let next_num = queue
.tasks
.iter()
.filter_map(|t| {
t.id.strip_prefix("YARD-")
.and_then(|n| n.parse::<usize>().ok())
})
.max()
.unwrap_or(queue.tasks.len())
+ 1;
let base_priority = queue.tasks.iter().map(|t| t.priority).max().unwrap_or(0);
for (i, pt) in plan.tasks.iter().enumerate() {
let id = if pt.id.trim().is_empty() {
format!("YARD-{:03}", next_num + i)
} else {
pt.id.clone()
};
let prior_ids: Vec<String> = queue.tasks.iter().map(|t| t.id.clone()).collect();
let mut task = Task {
id,
title: pt.title.clone(),
state: TaskState::Queued,
priority: base_priority + ((i + 1) * 10) as i64,
risk: pt.risk.clone(),
kind: pt.kind.clone(),
preferred_worker: if pt.preferred_worker.trim().is_empty() {
"codex".to_string()
} else {
pt.preferred_worker.clone()
},
model: pt.model.clone(),
effort: pt.effort.clone(),
depends_on: sanitize_deps(&pt.depends_on, &prior_ids),
skills: pt.skills.clone(),
allowed_scope: pt.allowed_scope.clone(),
acceptance: pt
.acceptance
.iter()
.map(|s| yaml::Value::String(s.clone()))
.collect(),
validation: None,
approval: None,
interaction: None,
worker_rationale: pt.worker_rationale.clone(),
};
crate::routing::apply_forced_worker(&mut task);
queue.tasks.push(task);
}
plan.tasks.len()
}
pub fn recover_unconsumed_plan(ws: &Workspace) -> Option<String> {
let mut best: Option<(String, std::path::PathBuf)> = None;
let mut newest_finished: Option<String> = None;
let mut live_planner: Option<(String, u32)> = None;
for entry in std::fs::read_dir(ws.runs_dir()).ok()?.flatten() {
let dir = entry.path();
let Some(name) = dir.file_name().and_then(|n| n.to_str()).map(String::from) else {
continue;
};
if !name.starts_with("plan-") {
continue;
}
if !dir.join("planning-result.json").is_file() {
if let Some(pid) = crate::run::live_worker_pid(&dir) {
if live_planner
.as_ref()
.map(|(n, _)| name > *n)
.unwrap_or(true)
{
live_planner = Some((name, pid));
}
}
continue;
}
if newest_finished.as_ref().map(|n| name > *n).unwrap_or(true) {
newest_finished = Some(name.clone());
}
let has_meta = dir.join("plan-meta.yaml").is_file() || workers::packet_path(&dir).is_file();
if !has_meta || dir.join(CONSUMED_MARKER).exists() {
continue;
}
if best.as_ref().map(|(n, _)| name > *n).unwrap_or(true) {
best = Some((name, dir));
}
}
let Some((run_id, run_dir)) = best else {
return live_planner.map(|(name, pid)| {
format!(
"a planning worker from a previous session is still running \
({name}, pid {pid}); its plan will be picked up when it finishes"
)
});
};
if newest_finished
.as_deref()
.is_some_and(|n| n > run_id.as_str())
{
mark_consumed(&run_dir);
return None;
}
let result_path = run_dir.join("planning-result.json");
let result_mtime = std::fs::metadata(&result_path)
.and_then(|m| m.modified())
.ok()?;
if let Ok(queue_mtime) = std::fs::metadata(ws.queue_path()).and_then(|m| m.modified()) {
if result_mtime <= queue_mtime {
mark_consumed(&run_dir);
return None;
}
}
let raw = std::fs::read_to_string(&result_path).ok()?;
let plan: PlanningResult = serde_json::from_str(&raw).ok()?;
if plan.tasks.is_empty() {
mark_consumed(&run_dir); return None;
}
let meta: PlanMeta = state::load_yaml(&run_dir.join("plan-meta.yaml"))
.ok()
.or_else(|| legacy_plan_meta(&run_dir))
.unwrap_or_default();
if meta.mode == "amend" {
let mut queue = ws.load_queue().ok()?;
let added = append_plan_tasks(&mut queue, &plan);
ws.save_queue(&queue).ok()?;
if let Ok(Some(mut intent)) = ws.load_intent() {
if !plan.summary.trim().is_empty() {
intent.summary =
format!("{}\n\n[follow-up] {}", intent.summary, plan.summary.trim());
let _ = state::save_yaml(&ws.intent_path(), &intent);
}
}
mark_consumed(&run_dir);
return Some(format!(
"recovered interrupted follow-up plan ({run_id}): +{added} task(s)"
));
}
if plan.summary.trim().is_empty() {
mark_consumed(&run_dir);
return None;
}
let intent_id = format!("intent-{}", Local::now().format("%Y%m%d-%H%M%S"));
let intent = build_intent(&intent_id, &meta.request, &plan, &[]);
let queue = build_queue(&intent_id, &plan);
let _ = crate::report::archive_intent(ws);
state::save_yaml(&ws.intent_path(), &intent).ok()?;
ws.save_queue(&queue).ok()?;
mark_consumed(&run_dir);
Some(format!(
"recovered interrupted plan ({run_id}): {} ({} tasks)",
intent.summary,
queue.tasks.len()
))
}
fn build_intent(
intent_id: &str,
request: &str,
plan: &PlanningResult,
images: &[String],
) -> IntentContract {
let mut open_questions: Vec<String> = plan
.questions_for_user
.iter()
.map(|q| match q {
PlanQuestion::Text(s) => s.clone(),
PlanQuestion::Obj {
question,
statement,
} => {
if !question.trim().is_empty() {
question.clone()
} else {
statement.clone()
}
}
})
.filter(|q| !q.trim().is_empty())
.collect();
for q in &plan.ambiguity.open_questions {
if !q.trim().is_empty() && !open_questions.contains(q) {
open_questions.push(q.clone());
}
}
IntentContract {
schema_version: 1,
id: intent_id.to_string(),
source: "user".to_string(),
raw_request: request.to_string(),
summary: plan.summary.clone(),
allowed_scope: plan.allowed_scope.clone(),
out_of_scope: plan.out_of_scope.clone(),
acceptance: plan
.acceptance
.iter()
.filter(|a| !a.statement.trim().is_empty())
.map(|a| yaml::Value::String(a.statement.clone()))
.collect(),
images: images.to_vec(),
ambiguity: plan.ambiguity.score.to_lowercase(),
open_questions,
clarifications: Vec::new(),
interview_turns: 0,
status: "accepted".to_string(),
}
}
fn build_queue(intent_id: &str, plan: &PlanningResult) -> WorkQueue {
let mut tasks: Vec<Task> = Vec::with_capacity(plan.tasks.len());
for (i, t) in plan.tasks.iter().enumerate() {
let prior_ids: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
let mut task = Task {
id: if t.id.trim().is_empty() {
format!("YARD-{:03}", i + 1)
} else {
t.id.clone()
},
title: t.title.clone(),
state: TaskState::Queued,
priority: ((i + 1) * 10) as i64,
risk: t.risk.clone(),
kind: t.kind.clone(),
preferred_worker: if t.preferred_worker.trim().is_empty() {
"codex".to_string()
} else {
t.preferred_worker.clone()
},
model: t.model.clone(),
effort: t.effort.clone(),
depends_on: sanitize_deps(&t.depends_on, &prior_ids),
skills: t.skills.clone(),
allowed_scope: t.allowed_scope.clone(),
acceptance: t
.acceptance
.iter()
.map(|s| yaml::Value::String(s.clone()))
.collect(),
validation: None,
approval: None,
interaction: None,
worker_rationale: t.worker_rationale.clone(),
};
crate::routing::apply_forced_worker(&mut task);
tasks.push(task);
}
ensure_review_task(&mut tasks);
WorkQueue {
schema_version: 1,
queue_id: format!("queue-{intent_id}"),
intent_id: intent_id.to_string(),
selection_policy: SelectionPolicy::default(),
tasks,
}
}
fn ensure_review_task(tasks: &mut Vec<Task>) {
let risky = tasks.iter().any(|t| t.risk.eq_ignore_ascii_case("high"));
let sizable = tasks.len() >= 3;
let has_review = tasks.iter().any(|t| t.kind.eq_ignore_ascii_case("review"));
if !(risky || sizable) || has_review || tasks.is_empty() {
return;
}
let next_num = tasks
.iter()
.filter_map(|t| {
t.id.strip_prefix("YARD-")
.and_then(|n| n.parse::<usize>().ok())
})
.max()
.unwrap_or(tasks.len())
+ 1;
let depends_on: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
let priority = tasks.iter().map(|t| t.priority).max().unwrap_or(0) + 10;
tasks.push(Task {
id: format!("YARD-{next_num:03}"),
title: "Acceptance review (auto-added)".to_string(),
state: TaskState::Queued,
priority,
risk: "low".to_string(),
kind: "review".to_string(),
preferred_worker: String::new(), model: String::new(),
effort: String::new(),
depends_on,
skills: vec![],
allowed_scope: vec![],
acceptance: vec![yaml::Value::String(
"Every intent acceptance criterion is verified against the actual workspace, \
with per-criterion pass/fail and evidence in report.md"
.to_string(),
)],
validation: None,
approval: None,
interaction: None,
worker_rationale: Some(
"deterministic semantic-verification rung: the verifier is never the doer".to_string(),
),
});
}
fn build_worker_guidance(workers: &WorkersFile) -> String {
let mut g = format!("Cost bias: {}.\n", workers.routing.cost_bias);
for w in &workers.workers {
if w.best_for.is_empty() {
continue;
}
let cost = if w.cost_weight.is_empty() {
"?"
} else {
&w.cost_weight
};
g.push_str(&format!(
"- {} (cost: {}): best for {}.",
w.id, cost, w.best_for
));
if !w.not_for.is_empty() {
g.push_str(&format!(" Avoid for {}.", w.not_for));
}
g.push('\n');
}
g
}
pub(crate) fn pick_ready_worker(
workers: &WorkersFile,
billing: &crate::schemas::BillingPolicy,
worker_override: Option<&str>,
) -> Result<(WorkerProfile, std::path::PathBuf, String)> {
let mut order: Vec<String> = Vec::new();
if let Some(o) = worker_override {
order.push(o.to_string());
}
let pg = &workers.routing.planning_gate;
for v in [&pg.primary, &pg.fallback] {
if !v.is_empty() && v != "none" {
order.push(v.clone());
}
}
order.push("codex".to_string());
let mut tried = Vec::new();
for id in order {
if tried.contains(&id) {
continue;
}
tried.push(id.clone());
let Some(profile) = workers.workers.iter().find(|w| w.id == id) else {
continue;
};
if !profile.enabled {
continue;
}
let status = guard::probe(profile, billing);
if status.readiness == Readiness::Ready {
if let Some(bin) = status.binary_path {
return Ok((profile.clone(), bin, id));
}
}
}
Err(anyhow!(
"no ready planning worker among {tried:?}. Run `yardlet worker status` to diagnose. \
Yardlet did not call an AI API and did not ask for an API key."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_guidance_has_contrastive_positive_and_negative_lines() {
let yaml = r#"
schema_version: 1
workers:
- id: codex
best_for: scoped edits
not_for: ambiguous specs
cost_weight: low
invocation: { command: codex }
- id: claude-code
best_for: refactors
cost_weight: high
invocation: { command: claude }
- id: blankworker
cost_weight: low
invocation: { command: blankworker }
routing:
cost_bias: balanced
"#;
let wf: WorkersFile = serde_yaml_ng::from_str(yaml).expect("workers yaml parses");
let g = build_worker_guidance(&wf);
assert!(g.contains("Cost bias: balanced."));
assert!(
g.contains("- codex (cost: low): best for scoped edits. Avoid for ambiguous specs.\n")
);
assert!(g.contains("- claude-code (cost: high): best for refactors.\n"));
assert!(!g.contains("best for refactors. Avoid"));
assert!(!g.contains("blankworker"));
}
#[test]
fn questions_accept_object_or_string_shape() {
let json = r#"{
"summary": "do a thing",
"tasks": [{ "id": "YARD-001", "title": "t" }],
"questions_for_user": [
{ "id": "Q1", "question": "scope ok?", "topic": "scope" },
"plain string question",
{ "id": "Q2", "statement": "fallback to statement" }
]
}"#;
let plan: PlanningResult =
serde_json::from_str(json).expect("both question shapes must parse");
let qs: Vec<String> = plan
.questions_for_user
.into_iter()
.map(PlanQuestion::into_text)
.collect();
assert_eq!(
qs,
vec![
"scope ok?".to_string(),
"plain string question".to_string(),
"fallback to statement".to_string(),
]
);
}
#[test]
fn recovers_unconsumed_plan_after_restart() {
let root = std::env::temp_dir().join(format!("yard-planrec-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let run_dir = ws.runs_dir().join("plan-20990101-000000");
std::fs::create_dir_all(&run_dir).unwrap();
state::save_yaml(
&run_dir.join("plan-meta.yaml"),
&PlanMeta {
mode: "new".into(),
request: "add admin search".into(),
},
)
.unwrap();
write_str(
&run_dir.join("planning-result.json"),
r#"{ "summary": "admin search",
"tasks": [{ "id": "YARD-001", "title": "t" }] }"#,
)
.unwrap();
let msg = recover_unconsumed_plan(&ws).expect("plan should be recovered");
assert!(msg.contains("admin search"));
let queue = ws.load_queue().unwrap();
assert_eq!(queue.tasks.len(), 1);
let intent = ws.load_intent().unwrap().unwrap();
assert_eq!(intent.raw_request, "add admin search");
assert!(recover_unconsumed_plan(&ws).is_none());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn review_task_is_guaranteed_for_risky_or_sizable_plans() {
let plan = |n: usize, risk: &str, with_review: bool| -> WorkQueue {
let mut json_tasks: Vec<String> = (1..=n)
.map(|i| {
format!(
r#"{{ "id": "YARD-{i:03}", "title": "t{i}", "risk": "{risk}", "kind": "implementation" }}"#
)
})
.collect();
if with_review {
json_tasks.push(
r#"{ "id": "YARD-099", "title": "review", "kind": "review" }"#.to_string(),
);
}
let json = format!(
r#"{{ "summary": "s", "tasks": [{}] }}"#,
json_tasks.join(",")
);
let p: PlanningResult = serde_json::from_str(&json).unwrap();
build_queue("i", &p)
};
let q = plan(1, "high", false);
assert_eq!(q.tasks.len(), 2);
let review = q.tasks.last().unwrap();
assert_eq!(review.kind, "review");
assert_eq!(review.id, "YARD-002");
assert_eq!(review.depends_on, vec!["YARD-001"]);
let q = plan(3, "low", false);
assert_eq!(q.tasks.len(), 4);
assert_eq!(
q.tasks.last().unwrap().depends_on,
vec!["YARD-001", "YARD-002", "YARD-003"]
);
let q = plan(3, "high", true);
assert_eq!(q.tasks.iter().filter(|t| t.kind == "review").count(), 1);
let q = plan(2, "low", false);
assert_eq!(q.tasks.len(), 2);
}
#[test]
fn goal_builds_a_two_task_queue_with_a_separate_verifier() {
let root = std::env::temp_dir().join(format!("yard-goal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let n = plan_goal(&ws, "fix the login redirect", None, None).unwrap();
assert_eq!(n, 1);
let q = ws.load_queue().unwrap();
assert_eq!(q.tasks.len(), 1);
assert_eq!(q.tasks[0].kind, "implementation");
let intent = ws.load_intent().unwrap().unwrap();
assert_eq!(intent.ambiguity, "low");
assert!(!intent_gated(&intent, true));
let n = plan_goal(
&ws,
"polish the title screen",
Some("no clipped text and the theme is consistent"),
None,
)
.unwrap();
assert_eq!(n, 2);
let q = ws.load_queue().unwrap();
assert_eq!(q.tasks[1].kind, "review");
assert_eq!(q.tasks[1].depends_on, vec!["YARD-001"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn image_asset_goal_is_recorded_as_codex_work() {
let root = std::env::temp_dir().join(format!("yard-image-goal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let n = plan_goal(
&ws,
"generate icon assets for the settings page",
None,
None,
)
.unwrap();
assert_eq!(n, 1);
let q = ws.load_queue().unwrap();
assert_eq!(q.tasks[0].preferred_worker, "codex");
assert_eq!(
q.tasks[0].worker_rationale.as_deref(),
Some("hard image/asset generation route: Codex has the image-generation capability")
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn ambiguity_gate_logic() {
let mut intent = IntentContract {
schema_version: 1,
id: "i".into(),
source: String::new(),
raw_request: String::new(),
summary: String::new(),
allowed_scope: vec![],
out_of_scope: vec![],
acceptance: vec![],
images: vec![],
ambiguity: "high".into(),
open_questions: vec!["which auth provider?".into()],
clarifications: vec![],
interview_turns: 0,
status: String::new(),
};
assert!(intent_gated(&intent, true));
assert!(!intent_gated(&intent, false)); intent.ambiguity = "medium".into();
assert!(!intent_gated(&intent, true)); intent.ambiguity = "high".into();
intent.interview_turns = INTERVIEW_CAP;
assert!(!intent_gated(&intent, true)); intent.interview_turns = 0;
intent.open_questions.clear();
assert!(!intent_gated(&intent, true)); }
#[test]
fn intent_records_planner_ambiguity_and_questions() {
let json = r#"{
"summary": "s",
"tasks": [{ "id": "YARD-001", "title": "t" }],
"ambiguity": { "score": "HIGH", "open_questions": ["q1", "q2"] },
"questions_for_user": ["q1", "q3"]
}"#;
let plan: PlanningResult = serde_json::from_str(json).unwrap();
let intent = build_intent("i", "req", &plan, &[]);
assert_eq!(intent.ambiguity, "high");
assert_eq!(intent.open_questions, vec!["q1", "q3", "q2"]);
}
#[test]
fn superseded_plan_is_never_recovered_over_a_newer_one() {
let root = std::env::temp_dir().join(format!("yard-stale-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let newer = ws.runs_dir().join("plan-20990202-000000");
std::fs::create_dir_all(&newer).unwrap();
write_str(&newer.join("planning-result.json"), "{}").unwrap();
write_str(&newer.join(CONSUMED_MARKER), "").unwrap();
ws.save_queue(&WorkQueue {
schema_version: 1,
queue_id: "q".into(),
intent_id: "live".into(),
selection_policy: Default::default(),
tasks: vec![],
})
.unwrap();
let stale = ws.runs_dir().join("plan-20990101-000000");
std::fs::create_dir_all(&stale).unwrap();
state::save_yaml(
&stale.join("plan-meta.yaml"),
&PlanMeta {
mode: "new".into(),
request: "old request".into(),
},
)
.unwrap();
write_str(
&stale.join("planning-result.json"),
r#"{ "summary": "stale plan",
"tasks": [{ "id": "YARD-001", "title": "t" }] }"#,
)
.unwrap();
assert!(recover_unconsumed_plan(&ws).is_none());
assert_eq!(ws.load_queue().unwrap().intent_id, "live");
assert!(stale.join(CONSUMED_MARKER).exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn reports_a_still_running_planning_worker() {
let root = std::env::temp_dir().join(format!("yard-liveplan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let dir = ws.runs_dir().join("plan-20990101-000000");
std::fs::create_dir_all(&dir).unwrap();
write_str(&dir.join("worker.pid"), &std::process::id().to_string()).unwrap();
let msg = recover_unconsumed_plan(&ws).expect("live planner should be reported");
assert!(msg.contains("still running"), "{msg}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recovers_legacy_plan_without_meta_file() {
let root = std::env::temp_dir().join(format!("yard-planrec-legacy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = Workspace::at(&root);
let run_dir = ws.runs_dir().join("plan-20990101-000000");
std::fs::create_dir_all(&run_dir).unwrap();
write_str(
&workers::packet_path(&run_dir),
"# Yardlet planning gate\n\n## Request (verbatim)\n\n\
make the game feel like a game\n\n## Rules\n\n- ...\n",
)
.unwrap();
write_str(
&run_dir.join("planning-result.json"),
r#"{ "summary": "game feel",
"tasks": [{ "id": "YARD-101", "title": "t" }] }"#,
)
.unwrap();
let msg = recover_unconsumed_plan(&ws).expect("legacy plan should be recovered");
assert!(msg.contains("game feel"));
let intent = ws.load_intent().unwrap().unwrap();
assert_eq!(intent.raw_request, "make the game feel like a game");
assert!(recover_unconsumed_plan(&ws).is_none());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn queue_keeps_only_backward_dependencies() {
let json = r#"{
"summary": "do a thing",
"tasks": [
{ "id": "YARD-001", "title": "a" },
{ "id": "YARD-002", "title": "b",
"depends_on": ["YARD-001", "YARD-002", "YARD-003", "NOPE"] },
{ "id": "YARD-003", "title": "c", "depends_on": ["YARD-001"] }
]
}"#;
let plan: PlanningResult = serde_json::from_str(json).unwrap();
let q = build_queue("intent-x", &plan);
assert!(q.tasks[0].depends_on.is_empty());
assert_eq!(q.tasks[1].depends_on, vec!["YARD-001".to_string()]);
assert_eq!(q.tasks[2].depends_on, vec!["YARD-001".to_string()]);
}
}