use crate::inspect::RepoSummary;
use crate::schemas::{ConversationTurn, IntentContract, Task, TurnRole};
pub struct PacketInputs<'a> {
pub worker_id: &'a str,
pub task: &'a Task,
pub intent: Option<&'a IntentContract>,
pub repo: &'a RepoSummary,
pub run_dir_rel: &'a str,
pub conversation: &'a [ConversationTurn],
pub continuation: Option<&'a str>,
pub chained_from: Option<&'a str>,
pub language: &'a str,
pub images: &'a [String],
pub role_notes: &'a str,
pub harness: &'a Harness,
}
pub fn detect_images(text: &str, cwd: &std::path::Path) -> Vec<String> {
const EXTS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
let mut out: Vec<String> = Vec::new();
for raw in text.split_whitespace() {
let tok =
raw.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | ',' | '(' | ')' | '<' | '>'));
let lower = tok.to_lowercase();
if !EXTS.iter().any(|e| lower.ends_with(e)) {
continue;
}
let p = if std::path::Path::new(tok).is_absolute() {
std::path::PathBuf::from(tok)
} else {
cwd.join(tok)
};
if p.is_file() {
let s = p.to_string_lossy().into_owned();
if !out.contains(&s) {
out.push(s);
}
}
}
out
}
pub fn resolve_language(configured: &str, sample: &str) -> String {
if !configured.is_empty() && configured != "auto" {
return configured.to_string();
}
if sample
.chars()
.any(|c| ('\u{AC00}'..='\u{D7A3}').contains(&c))
{
"ko".to_string()
} else {
"en".to_string()
}
}
fn language_name(code: &str) -> &str {
match code {
"ko" => "Korean",
"ja" => "Japanese",
"zh" => "Chinese",
"es" => "Spanish",
"fr" => "French",
"de" => "German",
_ => "English",
}
}
fn language_directive(code: &str) -> String {
if code == "en" || code.is_empty() {
return String::new();
}
format!(
"## Language\n\nWrite all user-facing content in {lang}: the plan summary, task titles, \
acceptance text, the handoff, any question_for_user, and result `compact_summary`. Keep \
code, identifiers, file paths, commands, and JSON/YAML keys in English.\n\n",
lang = language_name(code)
)
}
pub fn role_for(kind: &str) -> &'static str {
match kind.trim().to_lowercase().as_str() {
"review" => "reviewer",
"research" => "researcher",
"safety" => "security",
_ => "builder",
}
}
fn role_guidance(role: &str) -> &'static str {
match role {
"reviewer" => {
"- You are reviewing, not building: read the code in scope and verify it \
against the acceptance criteria.\n\
- Every finding needs evidence \u{2014} file and line plus why it is a problem; \
verify by reading the actual code, not a diff summary.\n\
- Rate each finding (critical/major/minor) and propose a concrete fix.\n\
- Emit a structured `verdict` (one entry per acceptance criterion, \
pass/fail + evidence) in result.json; never pass a criterion you could \
not actually verify.\n\
- If ANY criterion fails, do NOT just stop at a wall of fail \u{2014} set \
`status` to `partial` and PROPOSE the fix in `follow_up_tasks`: one \
implementation task scoped to the failing findings, with `acceptance` \
listing exactly what must change (a builder, not you, will do it). \
Yardlet runs that fix, then re-runs THIS review to verify, with bounded \
retries. If you cannot name a concrete fix, set `status` to `needs_user` \
and ask instead.\n\
- Do not rewrite the code yourself; only fix something if it is trivial \
and clearly inside scope.\n\
- Your findings go in the required report.md, in clear prose.\n\n"
}
"researcher" => {
"- Answer the task's questions from local evidence: read the code, configs, \
and docs in scope, and cite a path for every claim.\n\
- Stay intent-locked: gather what the intent needs; do not expand into new work.\n\
- Prefer primary sources in the repo over assumptions, and say clearly when \
evidence is missing.\n\
- Make no production code changes; your deliverables are the result files \
and report.md.\n\n"
}
"security" => {
"- Audit the scoped code adversarially: authn/authz gaps, injection, unsafe \
input handling, secrets in code or logs, dangerous defaults.\n\
- Every finding needs evidence (file and line) and an exploit rationale; mark \
severity and give a minimal remediation.\n\
- Emit a structured `verdict` (per criterion, pass/fail + evidence) in \
result.json; if any criterion fails set `status` to `needs_user`, not `done`.\n\
- Do not commit fixes unless trivial and in scope. Never print or move secret \
values \u{2014} refer to them by path or name only.\n\
- Your findings go in the required report.md, in clear prose.\n\n"
}
_ => {
"- Stay strictly inside the allowed scope.\n\
- Make focused changes and run the listed validation locally.\n\
- You may use your own subagents/parallelism inside this task; the task \
scope and the boundaries below bind your whole agent tree.\n\
- Do not ask for code/architecture/diff review.\n\
- If you hit a genuine blocker or a gated action, stop and report it.\n\n"
}
}
}
pub fn load_role_notes(root: &std::path::Path, role: &str) -> String {
std::fs::read_to_string(
root.join(crate::state::STATE_DIR)
.join("agents")
.join(format!("{role}.md")),
)
.unwrap_or_default()
}
const RULES_INLINE_CAP: usize = 4 * 1024;
pub struct HarnessRule {
pub origin: String,
pub text: String,
pub native_to: Vec<String>,
}
pub struct HarnessSkill {
pub name: String,
pub description: String,
pub path: String,
pub native_to: Vec<String>,
}
pub struct HarnessMemory {
pub title: String,
pub summary: String,
pub path: String,
pub look_at: Vec<String>,
}
#[derive(Default)]
pub struct Harness {
pub rules: Vec<HarnessRule>,
pub skills: Vec<HarnessSkill>,
pub memory: Vec<HarnessMemory>,
}
fn parse_memory_doc(text: &str, fallback: &str) -> (String, String, Vec<String>) {
let unquote = |s: &str| s.trim().trim_matches(['"', '\'']).to_string();
let mut title = String::new();
let mut summary = String::new();
let mut look_at: Vec<String> = Vec::new();
let mut lines = text.lines();
let mut fm: Vec<&str> = Vec::new();
let mut body: Vec<&str> = Vec::new();
if text.trim_start().starts_with("---") {
for l in lines.by_ref() {
if l.trim() == "---" {
break; }
}
for l in lines.by_ref() {
if l.trim() == "---" {
break; }
fm.push(l);
}
}
for l in lines {
body.push(l);
}
let mut i = 0;
while i < fm.len() {
let t = fm[i].trim();
if let Some(v) = t.strip_prefix("name:").or_else(|| t.strip_prefix("title:")) {
if title.is_empty() {
title = unquote(v);
}
} else if let Some(v) = t
.strip_prefix("description:")
.or_else(|| t.strip_prefix("summary:"))
{
if summary.is_empty() {
summary = unquote(v);
}
} else if let Some(v) = t
.strip_prefix("look_at:")
.or_else(|| t.strip_prefix("paths:"))
{
let v = v.trim();
if v.is_empty() {
while i + 1 < fm.len() {
if let Some(item) = fm[i + 1].trim().strip_prefix("- ") {
look_at.push(unquote(item));
i += 1;
} else {
break;
}
}
} else {
for part in v.trim_start_matches('[').trim_end_matches(']').split(',') {
let p = unquote(part);
if !p.is_empty() {
look_at.push(p);
}
}
}
}
i += 1;
}
if title.is_empty() {
title = body
.iter()
.find_map(|l| l.trim().strip_prefix("# ").map(|h| h.trim().to_string()))
.unwrap_or_else(|| fallback.to_string());
}
if summary.is_empty() {
summary = body
.iter()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with('#'))
.unwrap_or("")
.to_string();
}
const CAP: usize = 140;
if summary.chars().count() > CAP {
summary = summary.chars().take(CAP - 1).collect::<String>() + "\u{2026}";
}
(title, summary, look_at)
}
pub fn discover_harness(root: &std::path::Path, discovery: bool) -> Harness {
let mut h = Harness::default();
let mut seen_rule_paths: Vec<(std::path::PathBuf, usize)> = Vec::new();
let push_rule = |h: &mut Harness,
seen: &mut Vec<(std::path::PathBuf, usize)>,
file: &std::path::Path,
origin: String,
native: Option<&str>| {
let Ok(text) = std::fs::read_to_string(file) else {
return;
};
let text = text.trim().to_string();
if text.is_empty() {
return;
}
let canon = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if let Some((_, idx)) = seen.iter().find(|(c, _)| *c == canon) {
if let Some(n) = native {
let entry = &mut h.rules[*idx];
if !entry.native_to.iter().any(|w| w == n) {
entry.native_to.push(n.to_string());
}
}
return;
}
seen.push((canon, h.rules.len()));
h.rules.push(HarnessRule {
origin,
text,
native_to: native.map(|n| vec![n.to_string()]).unwrap_or_default(),
});
};
let rules_dir = root.join(crate::state::STATE_DIR).join("rules");
let mut files: Vec<_> = std::fs::read_dir(&rules_dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.collect();
files.sort();
for f in &files {
let name = f.file_name().and_then(|n| n.to_str()).unwrap_or("rule");
push_rule(
&mut h,
&mut seen_rule_paths,
f,
format!(".agents/rules/{name}"),
None,
);
}
collect_skills(
&mut h,
&root.join(crate::state::STATE_DIR).join("skills"),
".agents/skills",
&[],
);
let mem_dir = root.join(crate::state::STATE_DIR).join("memory");
let mut mfiles: Vec<_> = std::fs::read_dir(&mem_dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.collect();
mfiles.sort();
for f in &mfiles {
let name = f.file_name().and_then(|n| n.to_str()).unwrap_or("memory");
if name.eq_ignore_ascii_case("README.md") {
continue;
}
let Ok(text) = std::fs::read_to_string(f) else {
continue;
};
if text.trim().is_empty() {
continue;
}
let stem = f.file_stem().and_then(|n| n.to_str()).unwrap_or(name);
let (title, summary, look_at) = parse_memory_doc(&text, stem);
h.memory.push(HarnessMemory {
title,
summary,
path: format!(".agents/memory/{name}"),
look_at,
});
}
if discovery {
push_rule(
&mut h,
&mut seen_rule_paths,
&root.join("AGENTS.md"),
"AGENTS.md".to_string(),
Some("codex"),
);
push_rule(
&mut h,
&mut seen_rule_paths,
&root.join("CLAUDE.md"),
"CLAUDE.md".to_string(),
Some("claude-code"),
);
collect_skills(
&mut h,
&root.join(".claude/skills"),
".claude/skills",
&["claude-code"],
);
let cursor = root.join(".cursor/rules");
let mut cfiles: Vec<_> = std::fs::read_dir(&cursor)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "md" || x == "mdc"))
.collect();
cfiles.sort();
for f in &cfiles {
let name = f.file_name().and_then(|n| n.to_str()).unwrap_or("rule");
push_rule(
&mut h,
&mut seen_rule_paths,
f,
format!(".cursor/rules/{name}"),
None,
);
}
push_rule(
&mut h,
&mut seen_rule_paths,
&root.join(".github/copilot-instructions.md"),
".github/copilot-instructions.md".to_string(),
None,
);
}
h.skills.sort_by(|a, b| a.name.cmp(&b.name));
h
}
fn collect_skills(h: &mut Harness, dir: &std::path::Path, prefix: &str, native_to: &[&str]) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let skill_md = entry.path().join("SKILL.md");
let Ok(text) = std::fs::read_to_string(&skill_md) else {
continue;
};
let dir_name = entry.file_name().to_string_lossy().into_owned();
let name = frontmatter_field(&text, "name").unwrap_or(dir_name.clone());
if h.skills.iter().any(|s| s.name == name) {
continue; }
h.skills.push(HarnessSkill {
name,
description: frontmatter_field(&text, "description").unwrap_or_default(),
path: format!("{prefix}/{dir_name}/SKILL.md"),
native_to: native_to.iter().map(|s| s.to_string()).collect(),
});
}
}
fn frontmatter_field(text: &str, key: &str) -> Option<String> {
let rest = text.strip_prefix("---")?;
let block = rest.split("\n---").next()?;
block.lines().find_map(|l| {
l.strip_prefix(key)
.and_then(|r| r.trim_start().strip_prefix(':'))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
fn push_harness_sections(
p: &mut String,
harness: &Harness,
worker_id: &str,
required_skills: &[String],
) {
let native = |list: &[String]| list.iter().any(|w| w == worker_id);
let mut inlined = String::new();
let mut anchored: Vec<&str> = Vec::new();
for r in &harness.rules {
if native(&r.native_to) {
continue;
}
if inlined.len() + r.text.len() > RULES_INLINE_CAP {
anchored.push(&r.origin);
continue;
}
inlined.push_str(&format!("### {}\n{}\n\n", r.origin, r.text));
}
let inlined = inlined.trim();
if !inlined.is_empty() || !anchored.is_empty() {
p.push_str("## Workspace rules (always apply)\n\n");
if !inlined.is_empty() {
p.push_str(inlined);
p.push_str("\n\n");
}
for a in &anchored {
p.push_str(&format!("- also read and follow: `{a}`\n"));
}
if !anchored.is_empty() {
p.push('\n');
}
}
let skills: Vec<&HarnessSkill> = harness
.skills
.iter()
.filter(|s| !native(&s.native_to))
.collect();
if !skills.is_empty() {
p.push_str("## Skills (read on demand)\n\n");
p.push_str(
"Reusable procedures for this workspace. Before work a skill clearly applies \
to, read its SKILL.md first (the folder may hold deeper reference files \
\u{2014} read those only as needed):\n",
);
for s in &skills {
p.push_str(&format!(
"- {} \u{2014} {} (`{}`)\n",
s.name, s.description, s.path
));
}
if !required_skills.is_empty() {
p.push_str("\nRequired for THIS task (read before starting):\n");
for name in required_skills {
let path = harness
.skills
.iter()
.find(|s| &s.name == name)
.map(|s| s.path.clone())
.unwrap_or_else(|| format!(".agents/skills/{name}/SKILL.md"));
p.push_str(&format!("- `{path}`\n"));
}
}
p.push('\n');
}
if !harness.memory.is_empty() {
p.push_str("## Project memory (read on demand)\n\n");
p.push_str(
"Durable facts and decisions about this workspace. Read an entry's file when it \
bears on the task:\n",
);
for m in &harness.memory {
if m.summary.is_empty() {
p.push_str(&format!("- {} (`{}`)\n", m.title, m.path));
} else {
p.push_str(&format!(
"- {} \u{2014} {} (`{}`)\n",
m.title, m.summary, m.path
));
}
}
p.push('\n');
}
}
pub fn compile(inputs: &PacketInputs) -> String {
let role = role_for(&inputs.task.kind);
let mut p = String::new();
p.push_str(&format!("# Yardlet task packet: {}\n\n", inputs.task.id));
p.push_str(&format!(
"You are a hidden Yardlet worker ({}) acting as the {role}. Do the work below and \
leave structured artifacts. Console prose is not enough.\n\n",
inputs.worker_id
));
if let Some(intent) = inputs.intent {
p.push_str("## Intent\n\n");
if !intent.summary.is_empty() {
p.push_str(&format!("{}\n\n", intent.summary));
}
if !intent.allowed_scope.is_empty() {
p.push_str("Allowed scope:\n");
for s in &intent.allowed_scope {
p.push_str(&format!("- {s}\n"));
}
p.push('\n');
}
if !intent.out_of_scope.is_empty() {
p.push_str("Out of scope (do not touch):\n");
for s in &intent.out_of_scope {
p.push_str(&format!("- {s}\n"));
}
p.push('\n');
}
}
push_harness_sections(
&mut p,
inputs.harness,
inputs.worker_id,
&inputs.task.skills,
);
p.push_str("## Task\n\n");
p.push_str(&format!(
"**{}** ({})\n\n",
inputs.task.title, inputs.task.kind
));
if !inputs.task.allowed_scope.is_empty() {
p.push_str("Task scope:\n");
for s in &inputs.task.allowed_scope {
p.push_str(&format!("- {s}\n"));
}
p.push('\n');
}
if !inputs.task.acceptance.is_empty() {
p.push_str("Acceptance:\n");
for a in &inputs.task.acceptance {
if let Some(s) = a.as_str() {
p.push_str(&format!("- {s}\n"));
}
}
p.push('\n');
}
if !inputs.images.is_empty() {
p.push_str("## Attached images\n\n");
p.push_str("The user attached these local images; read/inspect them as needed:\n");
for img in inputs.images {
p.push_str(&format!("- {img}\n"));
}
p.push('\n');
}
if let Some(prev) = inputs.chained_from {
p.push_str(&format!(
"## Same session, next task\n\nYou just completed task {prev} in this session. \
The packet below is the NEXT task; reuse everything you already know about \
this repo \u{2014} do not re-explore what you have already read \u{2014} but treat \
the new task's scope and acceptance as the contract.\n\n"
));
}
if let Some(cont) = inputs.continuation {
p.push_str("## Continuing a partial run\n\n");
p.push_str(
"A previous run completed PART of this task. Continue from the checkpoint \
below \u{2014} do not redo finished work; close the remaining gaps and meet \
the acceptance criteria.\n\n",
);
p.push_str(cont.trim());
p.push_str("\n\n");
}
if !inputs.conversation.is_empty() {
p.push_str("## Conversation with the user\n\n");
p.push_str(
"This task paused to talk with the user. The full exchange so far, oldest \
first:\n\n",
);
for turn in inputs.conversation {
let who = match turn.role {
TurnRole::Worker => "you",
TurnRole::User => "user",
};
for (i, line) in turn.text.trim().lines().enumerate() {
if i == 0 {
p.push_str(&format!("> [{who}] {line}\n"));
} else {
p.push_str(&format!("> {line}\n"));
}
}
}
p.push_str(
"\nRespond to the user's latest message:\n\
- If it gives you the decision or information you needed, proceed and complete the \
task. Do not redo work finished in an earlier turn.\n\
- If it is a question, or you still lack what you need to finish, do NOT force \
completion: put your full user-facing reply (the explanation plus the specific \
choice you need) in `question_for_user`, keep it self-contained (the user may not \
see report.md), and return status `needs_user`.\n\n",
);
}
p.push_str("## Read anchors (do not load unrelated docs)\n\n");
p.push_str("- .agents/intent-contract.yaml (read-only)\n");
p.push_str("- .agents/work-queue.yaml (read-only; do NOT write it)\n");
p.push_str(&format!(
"- {}/evidence/repo-summary.md\n",
inputs.run_dir_rel
));
p.push('\n');
p.push_str("## Local environment\n\n");
if !inputs.repo.test_commands.is_empty() {
p.push_str(&format!(
"Validation candidates: {}\n",
inputs.repo.test_commands.join(", ")
));
}
if !inputs.repo.package_managers.is_empty() {
p.push_str(&format!(
"Package managers: {}\n",
inputs.repo.package_managers.join(", ")
));
}
p.push('\n');
p.push_str(&format!("## How to work \u{2014} role: {role}\n\n"));
p.push_str(role_guidance(role));
if !inputs.role_notes.trim().is_empty() {
p.push_str("### Workspace role notes\n\n");
p.push_str(inputs.role_notes.trim());
p.push_str("\n\n");
}
p.push_str("## Boundaries \u{2014} proceed freely, but stop before dangerous actions\n\n");
p.push_str(
"Work freely on safe, reversible, local changes (edit/create files, run tests and \
linters, local read-only queries) without asking. But STOP and report it \u{2014} set \
`status` to `needs_user`, explain in `question_for_user` \u{2014} before any of these \
dangerous or irreversible actions, and do not attempt them:\n\
- deleting/overwriting files outside the workspace, or mass/irreversible deletion\n\
- git push, force-push, or tag push\n\
- deploy, publish, release, or package publish (npm/cargo/pip publish, etc.)\n\
- production database or infrastructure access or changes\n\
- sending external messages/emails/posts, or calling external mutating APIs\n\
- purchases, payments, or account changes\n\
- reading, writing, or exposing secrets/credentials, or editing CI secrets\n\
If a needed local action is denied by the sandbox (e.g. network or a package install), \
also stop and report what you need instead of trying to bypass it.\n\n",
);
p.push_str("## Proposing follow-up work\n\n");
p.push_str(
"If you find adjacent work worth doing later, do NOT edit \
`.agents/work-queue.yaml` \u{2014} Yardlet owns the queue. PROPOSE it in `result.json` \
under `follow_up_tasks`: each entry needs a `title` and a `reason` (why it exists), \
plus optional `kind`, `risk`, `allowed_scope`, `acceptance`, `skills`, `depends_on`, \
`preferred_worker`, `required_capabilities`. Yardlet assigns the id and priority, \
validates, dedups, and enqueues it as a tracked candidate. Stay within THIS task's \
scope; a follow-up is a candidate for later, not license to expand the current task.\n\n",
);
p.push_str(
"If a follow-up must run BEFORE work already queued (e.g. you hit a capability \
ceiling and a worker with the right `required_capabilities`/`preferred_worker` should \
take over first), set its `runs_before` to the ids of the existing tasks that depend on \
it \u{2014} Yardlet makes those tasks wait for it (a true \"insert between\"). For a softer \
\"just run this next\" nudge without hard dependencies, set `insert: \"next\"`.\n\n",
);
p.push_str(&language_directive(inputs.language));
p.push_str("## Required output\n\n");
p.push_str(&format!(
"Write these files (paths relative to repo root):\n\
- `{rd}/result.json`\n\
- `{rd}/handoff.md`\n\
- `{rd}/validation.log` (if you ran validation)\n\n",
rd = inputs.run_dir_rel
));
let kind = inputs.task.kind.trim();
if !kind.is_empty() && !kind.eq_ignore_ascii_case("implementation") {
p.push_str(&format!(
"Because this task is `{kind}` (not implementation), also write \
`{rd}/report.md` \u{2014} your findings/results in clear prose for a person to \
read, not just the JSON summary.\n\n",
rd = inputs.run_dir_rel
));
}
p.push_str("`result.json` shape:\n\n");
p.push_str(RESULT_SCHEMA_HINT);
p
}
#[allow(clippy::too_many_arguments)]
pub fn compile_planning(
request: &str,
repo: &RepoSummary,
run_dir_rel: &str,
language: &str,
worker_guidance: &str,
images: &[String],
harness: &Harness,
planner_worker_id: &str,
) -> String {
let mut p = String::new();
p.push_str("# Yardlet planning gate\n\n");
p.push_str(
"You are a hidden Yardlet planning worker. Turn the request below into a bounded, \
checkable work contract. Do NOT implement anything in this run.\n\n",
);
p.push_str("## Request (verbatim)\n\n");
p.push_str(request);
p.push_str("\n\n");
if !images.is_empty() {
p.push_str("## Attached images\n\n");
for img in images {
p.push_str(&format!("- {img}\n"));
}
p.push('\n');
}
p.push_str("## Local environment (evidence, not a task list)\n\n");
p.push_str(&format!("- root: `{}`\n", repo.root));
if !repo.package_managers.is_empty() {
p.push_str(&format!(
"- package managers: {}\n",
repo.package_managers.join(", ")
));
}
if !repo.test_commands.is_empty() {
p.push_str(&format!(
"- test commands: {}\n",
repo.test_commands.join(", ")
));
}
p.push_str(&format!("- top level: {}\n\n", repo.top_level.join(", ")));
push_harness_sections(&mut p, harness, planner_worker_id, &[]);
p.push_str("## Rules\n\n");
p.push_str(
"- Produce a goal summary, allowed scope, explicit out-of-scope, and a small tree of \
checkable acceptance criteria.\n\
- Break the work into a few bounded tasks. Each task: title, kind \
(research|implementation|review|safety), risk (low|medium|high), preferred_worker \
(one of the worker ids under Worker selection), model, effort, depends_on, \
allowed_scope, acceptance.\n\
- Cut tasks COARSE, along scope boundaries: each task is one bounded worker session \
with its own disjoint allowed_scope and independently checkable acceptance. Do NOT \
split work that shares context into micro-tasks \u{2014} the worker can parallelize \
internally (subagents) within one task. A good split: tasks could run in any order \
or in parallel without reading each other's changes.\n\
- Set `depends_on` to the ids of tasks whose OUTPUT this task genuinely needs \
(earlier tasks only). Leave it empty for independent tasks \u{2014} independent \
tasks may run in parallel. Order alone is not a dependency.\n\
- If a workspace skill (see Skills above) clearly applies to a task, list its \
name in that task's `skills` so the worker reads it before starting.\n\
- If any task is high-risk or the plan has 3+ tasks, END with a review-kind \
task that verifies the intent's acceptance criteria against the workspace \
(per-criterion pass/fail). If you omit it, Yardlet appends one.\n\
- Default model and effort to \"auto\" (let the chosen worker decide). Set them \
only when a task clearly needs a stronger or cheaper model, or more or less \
reasoning. Effort levels: minimal|low|medium|high (or \"auto\").\n\
- Do not expand the goal. Keep out-of-scope strict (payments, auth redesign, production \
DB, deploy) unless the request demands them.\n\
- Score `ambiguity` honestly: \"high\" means you would still be guessing product \
behavior or architecture \u{2014} it pauses the run and starts an interview with \
the user; put what you need answered in `ambiguity.open_questions`.\n\
- Ask at most 2 questions, and only about product intent / scope / acceptance priority. \
Put them in `questions_for_user`; do NOT block on them, proceed with explicit \
assumptions otherwise.\n\
- Never ask the user to review code, architecture, or diffs.\n\n",
);
if !worker_guidance.is_empty() {
p.push_str("## Worker selection\n\n");
p.push_str(worker_guidance);
p.push_str(
"\nFor each task, set `preferred_worker` to the best fit and give a one-line \
`worker_rationale`. Weigh the cost bias: prefer the cheaper worker for routine, \
well-scoped work; reserve the pricier one for hard, ambiguous, or broad tasks.\n\n",
);
}
p.push_str(&language_directive(language));
p.push_str("## Required output\n\n");
p.push_str(&format!(
"Write exactly one file: `{run_dir_rel}/planning-result.json`, matching this shape:\n\n"
));
p.push_str(PLANNING_SCHEMA_HINT);
p
}
pub fn compile_skill(
mode: &str,
subject: &str,
repo: &RepoSummary,
run_dir_rel: &str,
language: &str,
harness: &Harness,
worker_id: &str,
) -> String {
let mut p = String::new();
p.push_str("# Yardlet skill authoring\n\n");
p.push_str(&format!(
"You are a hidden Yardlet worker ({worker_id}) acting as the researcher. Author ONE \
reusable skill \u{2014} a portable SKILL.md (frontmatter + a concise procedure) \u{2014} \
for THIS repository. Do NOT implement repo changes in this run; your only deliverable \
is the skill draft written to the result file.\n\n",
));
if mode == "create" {
p.push_str(&format!(
"## Skill to create\n\n{subject}\n\nAuthor that skill: the procedure a future \
worker in this repo should follow for the capability. Keep the name you were given \
unless it is clearly wrong.\n\n"
));
} else {
p.push_str(&format!(
"## Topic to research\n\n{subject}\n\nIdentify the skill this repo needs for that \
topic, give it a short kebab-case `name`, and draft it. If a skill in the catalog \
below already covers it, say so in `rationale` and still draft the best version.\n\n"
));
}
p.push_str("## This repository (evidence)\n\n");
p.push_str(&format!("- root: `{}`\n", repo.root));
if !repo.package_managers.is_empty() {
p.push_str(&format!(
"- package managers: {}\n",
repo.package_managers.join(", ")
));
}
if !repo.test_commands.is_empty() {
p.push_str(&format!(
"- test commands: {}\n",
repo.test_commands.join(", ")
));
}
p.push_str(&format!("- top level: {}\n\n", repo.top_level.join(", ")));
push_harness_sections(&mut p, harness, worker_id, &[]);
p.push_str("## How to write the skill\n\n");
p.push_str(
"- Make it a reusable PROCEDURE, not a one-off answer: when to use it, the steps, and \
how to verify success.\n\
- Fit it to THIS repo's conventions (the evidence above); cite concrete paths/commands \
where it helps.\n\
- Keep it tight \u{2014} a focused half-page beats an exhaustive essay. One skill, one \
capability; don't duplicate a skill already in the catalog above.\n\
- `description` is one line for the catalog: what the skill is for, so a planner can \
match it to a task.\n\
- Network may be unavailable (sandbox); draft from the repo and your own knowledge, and \
note in `rationale` if a source you would want is unreachable.\n\n",
);
p.push_str(&language_directive(language));
p.push_str("## Required output\n\n");
p.push_str(&format!(
"Write exactly one file: `{run_dir_rel}/skill-result.json`, matching this shape:\n\n"
));
p.push_str(SKILL_SCHEMA_HINT);
p
}
const SKILL_SCHEMA_HINT: &str = r#"```json
{
"name": "kebab-case-skill-name",
"description": "One line: what this skill is for (planner-matchable).",
"body": "Markdown procedure. Use headings like ## When to use, ## Steps, ## Verify. This becomes the SKILL.md body verbatim.",
"rationale": "Why this skill, what gap it fills, and any source you could not reach."
}
```
Do NOT put YAML frontmatter inside `body` — Yardlet writes the `name`/`description`
frontmatter itself. `body` is just the Markdown procedure.
"#;
const PLANNING_SCHEMA_HINT: &str = r#"```json
{
"summary": "One sentence describing the goal in product terms.",
"allowed_scope": ["..."],
"out_of_scope": ["..."],
"acceptance": [
{ "id": "AC-001", "statement": "...", "evidence": ["..."] }
],
"ambiguity": { "score": "low|medium|high", "open_questions": ["..."] },
"tasks": [
{
"id": "YARD-001",
"title": "...",
"kind": "research|implementation|review|safety",
"risk": "low|medium|high",
"preferred_worker": "<a worker id from Worker selection>",
"model": "auto",
"effort": "auto",
"depends_on": ["YARD-001"],
"skills": ["<skill name from the catalog, when one applies>"],
"required_capabilities": ["<a worker capability this task hard-requires; omit if none>"],
"worker_rationale": "one line: why this worker fits this task",
"allowed_scope": ["..."],
"acceptance": ["..."]
}
],
"questions_for_user": ["a short, high-level question — only if something is genuinely ambiguous"]
}
```
"#;
const RESULT_SCHEMA_HINT: &str = r#"```json
{
"schema_version": 1,
"run_id": "<run-id>",
"task_id": "<task-id>",
"status": "done | partial | blocked | failed | needs_user",
"intent_adherence": { "drift_detected": false, "notes": "" },
"changes": { "files_modified": [], "files_created": [], "files_deleted": [] },
"validation": { "commands_run": [], "passed": true, "failures": [] },
"question_for_user": null,
"compact_summary": "Short resume summary for the next run.",
"verdict": [
{ "criterion_id": "AC-001", "pass": true, "evidence": "path/screenshot + why" }
],
"harness_suggestions": [
{ "kind": "rule|skill", "title": "...", "content": "short, imperative, reusable" }
],
"follow_up_tasks": [
{ "title": "...", "reason": "why this follow-up exists",
"kind": "implementation|review|...", "risk": "low|medium|high",
"acceptance": ["..."], "allowed_scope": ["..."], "depends_on": [],
"preferred_worker": "", "required_capabilities": [],
"insert": "end|next", "runs_before": [] }
]
}
```
`verdict` is REQUIRED for review/safety tasks: one entry per acceptance
criterion you checked, judged against the ACTUAL workspace (read the code,
run it, look at the screenshots) — not a restatement of intent. `pass: false`
with concrete evidence is the whole point; do not pass a criterion you could
not verify. Build tasks may leave `verdict` empty. Fill `harness_suggestions`
only when you learned something reusable about THIS repo. A "skill" suggestion
should be a self-contained procedure (how to do a recurring task in this repo)
that a future worker could follow; a "rule" is a short always-apply constraint.
Leave `follow_up_tasks` empty unless you found adjacent work worth queueing for
later; never edit `.agents/work-queue.yaml` yourself — Yardlet ingests these.
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_korean_and_respects_config() {
assert_eq!(resolve_language("auto", "관리자 주문 검색"), "ko");
assert_eq!(resolve_language("auto", "add admin order search"), "en");
assert_eq!(resolve_language("en", "관리자"), "en");
assert_eq!(resolve_language("ko", "english text"), "ko");
}
#[test]
fn directive_empty_for_english_only() {
assert!(language_directive("en").is_empty());
assert!(language_directive("").is_empty());
assert!(language_directive("ko").contains("Korean"));
}
#[test]
fn discovery_finds_native_and_borrowed_sources() {
let root = std::env::temp_dir().join(format!("yard-a1-disc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".agents/rules")).unwrap();
std::fs::create_dir_all(root.join(".agents/skills/native-skill")).unwrap();
std::fs::create_dir_all(root.join(".claude/skills/borrowed-skill")).unwrap();
std::fs::create_dir_all(root.join(".cursor/rules")).unwrap();
std::fs::create_dir_all(root.join(".github")).unwrap();
std::fs::write(root.join(".agents/rules/team.md"), "Ours first.").unwrap();
std::fs::write(root.join("AGENTS.md"), "Repo agent instructions.").unwrap();
std::fs::write(root.join("CLAUDE.md"), "Claude instructions.").unwrap();
std::fs::write(
root.join(".claude/skills/borrowed-skill/SKILL.md"),
"---\nname: borrowed-skill\ndescription: From claude dir.\n---\nbody",
)
.unwrap();
std::fs::write(
root.join(".agents/skills/native-skill/SKILL.md"),
"---\nname: native-skill\ndescription: Ours.\n---\nbody",
)
.unwrap();
std::fs::write(root.join(".cursor/rules/style.mdc"), "Cursor style rule.").unwrap();
std::fs::write(
root.join(".github/copilot-instructions.md"),
"Copilot notes.",
)
.unwrap();
let h = discover_harness(&root, true);
let origins: Vec<&str> = h.rules.iter().map(|r| r.origin.as_str()).collect();
assert_eq!(
origins,
vec![
".agents/rules/team.md",
"AGENTS.md",
"CLAUDE.md",
".cursor/rules/style.mdc",
".github/copilot-instructions.md"
]
);
let names: Vec<&str> = h.skills.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["borrowed-skill", "native-skill"]);
let borrowed = h
.skills
.iter()
.find(|s| s.name == "borrowed-skill")
.unwrap();
assert_eq!(borrowed.path, ".claude/skills/borrowed-skill/SKILL.md");
assert_eq!(borrowed.native_to, vec!["claude-code".to_string()]);
let h = discover_harness(&root, false);
assert_eq!(h.rules.len(), 1);
assert_eq!(h.skills.len(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn symlinked_claude_md_merges_into_one_rule_native_to_both() {
let root = std::env::temp_dir().join(format!("yard-a1-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("AGENTS.md"), "Shared instructions.").unwrap();
std::os::unix::fs::symlink(root.join("AGENTS.md"), root.join("CLAUDE.md")).unwrap();
let h = discover_harness(&root, true);
assert_eq!(h.rules.len(), 1);
let r = &h.rules[0];
assert_eq!(r.origin, "AGENTS.md");
assert!(r.native_to.contains(&"codex".to_string()));
assert!(r.native_to.contains(&"claude-code".to_string()));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn discovers_project_memory_as_index_with_bodies_on_demand() {
let root = std::env::temp_dir().join(format!("yard-mem-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".agents/memory")).unwrap();
std::fs::write(
root.join(".agents/memory/decisions.md"),
"---\nname: v0.8 decisions\ndescription: Loop-engineering tracks and the finalize_run \
foundation.\nlook_at:\n - src/run.rs\n - docs/v0.8-decisions.md\n---\n\n# inner \
heading\n\nLONGBODY that must not be inlined.",
)
.unwrap();
std::fs::write(
root.join(".agents/memory/conventions.md"),
"# Coding conventions\n\nMatch the surrounding code; small typed structs.",
)
.unwrap();
std::fs::write(root.join(".agents/memory/index.yaml"), "ignored: true").unwrap();
std::fs::write(
root.join(".agents/memory/README.md"),
"# Project memory\nDocs.",
)
.unwrap();
let h = discover_harness(&root, false);
let titles: Vec<&str> = h.memory.iter().map(|m| m.title.as_str()).collect();
assert_eq!(titles, vec!["Coding conventions", "v0.8 decisions"]);
assert_eq!(h.memory[1].path, ".agents/memory/decisions.md");
assert_eq!(
h.memory[1].look_at,
vec![
"src/run.rs".to_string(),
"docs/v0.8-decisions.md".to_string()
]
);
assert!(h.memory[0].look_at.is_empty()); assert_eq!(
h.memory[1].summary,
"Loop-engineering tracks and the finalize_run foundation."
);
assert_eq!(
h.memory[0].summary,
"Match the surrounding code; small typed structs."
);
let mut p = String::new();
push_harness_sections(&mut p, &h, "codex", &[]);
assert!(p.contains("## Project memory (read on demand)"));
assert!(p.contains("v0.8 decisions"));
assert!(p.contains(".agents/memory/decisions.md"));
assert!(
!p.contains("LONGBODY"),
"memory bodies must stay on-demand, not inlined: {p}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn projection_skips_natively_consumed_sources() {
let harness = Harness {
rules: vec![
HarnessRule {
origin: "CLAUDE.md".into(),
text: "Claude-native rule body.".into(),
native_to: vec!["claude-code".into()],
},
HarnessRule {
origin: ".cursor/rules/style.md".into(),
text: "Cursor rule body.".into(),
native_to: vec![],
},
],
skills: vec![HarnessSkill {
name: "borrowed-skill".into(),
description: "From claude dir.".into(),
path: ".claude/skills/borrowed-skill/SKILL.md".into(),
native_to: vec!["claude-code".into()],
}],
memory: vec![],
};
let mut for_claude = String::new();
push_harness_sections(&mut for_claude, &harness, "claude-code", &[]);
assert!(!for_claude.contains("Claude-native rule body."));
assert!(for_claude.contains("Cursor rule body."));
assert!(!for_claude.contains("borrowed-skill"));
let mut for_codex = String::new();
push_harness_sections(&mut for_codex, &harness, "codex", &[]);
assert!(for_codex.contains("Claude-native rule body."));
assert!(for_codex.contains("borrowed-skill"));
assert!(for_codex.contains(".claude/skills/borrowed-skill/SKILL.md"));
}
#[test]
fn rules_overflow_becomes_anchors() {
let harness = Harness {
rules: vec![
HarnessRule {
origin: "small.md".into(),
text: "fits".into(),
native_to: vec![],
},
HarnessRule {
origin: "big.md".into(),
text: "x".repeat(5000),
native_to: vec![],
},
],
skills: vec![],
memory: vec![],
};
let mut out = String::new();
push_harness_sections(&mut out, &harness, "codex", &[]);
assert!(out.contains("### small.md"));
assert!(out.contains("also read and follow: `big.md`"));
assert!(!out.contains("xxxxxxxxxx"));
}
#[test]
fn packet_carries_rules_catalog_and_required_skills() {
let task = crate::schemas::Task {
id: "YARD-1".into(),
title: "t".into(),
state: Default::default(),
priority: 0,
risk: String::new(),
kind: "implementation".into(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec![],
skills: vec!["deploy-check".into()],
required_capabilities: vec![],
allowed_scope: vec![],
acceptance: vec![],
validation: None,
approval: None,
interaction: None,
worker_rationale: None,
provenance: String::new(),
};
let repo = crate::inspect::RepoSummary::default();
let harness = Harness {
rules: vec![HarnessRule {
origin: "team.md".into(),
text: "Never push without review.".into(),
native_to: vec![],
}],
skills: vec![HarnessSkill {
name: "deploy-check".into(),
description: "Verify a deploy end to end.".into(),
path: ".agents/skills/deploy-check/SKILL.md".into(),
native_to: vec![],
}],
memory: vec![],
};
let p = compile(&PacketInputs {
worker_id: "codex",
task: &task,
intent: None,
repo: &repo,
run_dir_rel: ".agents/runs/run-x",
conversation: &[],
continuation: None,
chained_from: None,
language: "en",
images: &[],
role_notes: "",
harness: &harness,
});
assert!(p.contains("## Workspace rules (always apply)"));
assert!(p.contains("Never push without review."));
assert!(p.contains("## Skills (read on demand)"));
assert!(p.contains("deploy-check \u{2014} Verify a deploy end to end."));
assert!(p.contains("Required for THIS task"));
assert!(p.contains(".agents/skills/deploy-check/SKILL.md"));
let plan = compile_planning(
"do a thing",
&repo,
".agents/runs/plan-x",
"en",
"",
&[],
&harness,
"codex",
);
assert!(plan.contains("## Workspace rules (always apply)"));
assert!(plan.contains("## Skills (read on demand)"));
}
#[test]
fn kinds_map_to_role_profiles() {
assert_eq!(role_for("review"), "reviewer");
assert_eq!(role_for("Research"), "researcher");
assert_eq!(role_for("safety"), "security");
assert_eq!(role_for("implementation"), "builder");
assert_eq!(role_for(""), "builder"); }
fn packet_for(kind: &str, role_notes: &str) -> String {
let task = crate::schemas::Task {
id: "YARD-1".into(),
title: "t".into(),
state: Default::default(),
priority: 0,
risk: String::new(),
kind: kind.into(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec![],
skills: vec![],
required_capabilities: vec![],
allowed_scope: vec![],
acceptance: vec![],
validation: None,
approval: None,
interaction: None,
worker_rationale: None,
provenance: String::new(),
};
let repo = crate::inspect::RepoSummary::default();
compile(&PacketInputs {
worker_id: "codex",
task: &task,
intent: None,
repo: &repo,
run_dir_rel: ".agents/runs/run-x",
conversation: &[],
continuation: None,
chained_from: None,
language: "en",
images: &[],
role_notes,
harness: &Harness::default(),
})
}
#[test]
fn chained_packet_tells_the_worker_to_reuse_its_context() {
let task = crate::schemas::Task {
id: "YARD-2".into(),
title: "t".into(),
state: Default::default(),
priority: 0,
risk: String::new(),
kind: "implementation".into(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec!["YARD-1".into()],
skills: vec![],
required_capabilities: vec![],
allowed_scope: vec![],
acceptance: vec![],
validation: None,
approval: None,
interaction: None,
worker_rationale: None,
provenance: String::new(),
};
let repo = crate::inspect::RepoSummary::default();
let p = compile(&PacketInputs {
worker_id: "codex",
task: &task,
intent: None,
repo: &repo,
run_dir_rel: ".agents/runs/run-x",
conversation: &[],
continuation: None,
chained_from: Some("YARD-1"),
language: "en",
images: &[],
role_notes: "",
harness: &Harness::default(),
});
assert!(p.contains("## Same session, next task"));
assert!(p.contains("completed task YARD-1 in this session"));
assert!(p.contains("do not re-explore"));
}
#[test]
fn continuation_section_renders_for_partial_reruns() {
let task = crate::schemas::Task {
id: "YARD-1".into(),
title: "t".into(),
state: Default::default(),
priority: 0,
risk: String::new(),
kind: "implementation".into(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec![],
skills: vec![],
required_capabilities: vec![],
allowed_scope: vec![],
acceptance: vec![],
validation: None,
approval: None,
interaction: None,
worker_rationale: None,
provenance: String::new(),
};
let repo = crate::inspect::RepoSummary::default();
let p = compile(&PacketInputs {
worker_id: "codex",
task: &task,
intent: None,
repo: &repo,
run_dir_rel: ".agents/runs/run-x",
conversation: &[],
continuation: Some("- Checkpoint: AC-004 unmet (wrong background)"),
chained_from: None,
language: "en",
images: &[],
role_notes: "",
harness: &Harness::default(),
});
assert!(p.contains("## Continuing a partial run"));
assert!(p.contains("do not redo finished work"));
assert!(p.contains("AC-004 unmet"));
}
#[test]
fn conversation_renders_transcript_and_lets_the_worker_decide() {
let task = crate::schemas::Task {
id: "YARD-1".into(),
title: "decide renderer".into(),
state: Default::default(),
priority: 0,
risk: String::new(),
kind: "review".into(),
preferred_worker: String::new(),
model: String::new(),
effort: String::new(),
depends_on: vec![],
skills: vec![],
required_capabilities: vec![],
allowed_scope: vec![],
acceptance: vec![],
validation: None,
approval: None,
interaction: None,
worker_rationale: None,
provenance: String::new(),
};
let repo = crate::inspect::RepoSummary::default();
let turns = vec![
ConversationTurn {
role: TurnRole::Worker,
text: "Forward+ or GL Compatibility?".into(),
run_id: "run-1".into(),
ts: String::new(),
},
ConversationTurn {
role: TurnRole::User,
text: "what is Forward+?".into(),
run_id: String::new(),
ts: String::new(),
},
];
let p = compile(&PacketInputs {
worker_id: "codex",
task: &task,
intent: None,
repo: &repo,
run_dir_rel: ".agents/runs/run-x",
conversation: &turns,
continuation: None,
chained_from: None,
language: "en",
images: &[],
role_notes: "",
harness: &Harness::default(),
});
assert!(p.contains("## Conversation with the user"));
assert!(p.contains("[you] Forward+ or GL Compatibility?"));
assert!(p.contains("[user] what is Forward+?"));
assert!(p.contains("question_for_user"));
assert!(p.contains("needs_user"));
}
#[test]
fn packet_carries_role_guidance_and_workspace_notes() {
let review = packet_for("review", "");
assert!(review.contains("role: reviewer"));
assert!(review.contains("reviewing, not building"));
assert!(!review.contains("Workspace role notes"));
let build = packet_for("implementation", "Prefer small commits.");
assert!(build.contains("role: builder"));
assert!(build.contains("Workspace role notes"));
assert!(build.contains("Prefer small commits."));
}
#[test]
fn detects_only_existing_image_paths() {
let dir = std::env::temp_dir().join(format!("yard-img-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("shot.png"), b"x").unwrap();
let found = detect_images("see shot.png and notes.txt", &dir);
assert_eq!(found.len(), 1);
assert!(found[0].ends_with("shot.png"));
assert!(detect_images("see missing.jpg", &dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}