fix(compose): render resolved scope block — agent sees concrete paths, not generic text

Dogfood gap #2 from prepare workflow: capability text fragments say
"your task's scope" generically, but agent never sees the resolved
scope params from task.toml. Migration agent had no way to know
whitelist = kei-chat-store/** without me pasting it.

Fix: render_scope_block() injects a resolved-params section between
capability fragments and task body. Shows:
- files-whitelist / files-denylist glob lists
- cargo-check-crates / cargo-test-crates
- test-count-min (if Option<u32>::Some)
- report-fields-required

If no scope params set, block is empty (no section rendered).

Now `kei-agent-runtime prepare` emits fully self-contained prompt —
no external context needed. Substrate unblocked for parallel migrations.

Tests: 41/41 (was 41 — no regression; scope-block added tests deferred
to follow-up since compose_smoke already covers existing path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 05:44:15 +08:00
parent 68e37ecf68
commit e075ae8df1

View file

@ -18,23 +18,94 @@ const SEPARATOR: &str = "\n\n---\n\n";
/// Compose prompt text. `kit_root` is the repo root that holds `_roles/`
/// and `_capabilities/` directories.
///
/// Order: capability fragments → resolved scope block → task body.
/// The scope block makes whitelist/denylist/verification params visible
/// to the agent — capability text references "your task's scope" generically;
/// without this block the agent has no way to know concrete paths.
pub fn compose_prompt(task: &TaskSpec, kit_root: &Path) -> Result<String> {
if task.task.role.is_empty() {
return Err(anyhow!("task.role is empty"));
}
let resolved = resolve_role(kit_root, &task.task.role)?;
let mut fragments: Vec<String> = Vec::with_capacity(resolved.required.len() + 1);
let mut fragments: Vec<String> = Vec::with_capacity(resolved.required.len() + 2);
for cap_name in &resolved.required {
let frag = load_capability_text(kit_root, cap_name)
.with_context(|| format!("capability {cap_name}"))?;
fragments.push(frag);
}
let scope_block = render_scope_block(task);
if !scope_block.is_empty() {
fragments.push(scope_block);
}
if !task.body.text.trim().is_empty() {
fragments.push(task.body.text.clone());
}
Ok(fragments.join(SEPARATOR))
}
fn render_scope_block(task: &TaskSpec) -> String {
let mut lines = vec!["## Your task's scope (resolved from task.toml)".to_string()];
if !task.scope.files_whitelist.is_empty() {
lines.push(String::new());
lines.push("**files-whitelist** (you MAY Edit/Write these):".to_string());
for p in &task.scope.files_whitelist {
lines.push(format!("- `{p}`"));
}
}
if !task.scope.files_denylist.is_empty() {
lines.push(String::new());
lines.push("**files-denylist** (you MUST NOT Edit/Write these):".to_string());
for p in &task.scope.files_denylist {
lines.push(format!("- `{p}`"));
}
}
if !task.verification.cargo_check_crates.is_empty() {
lines.push(String::new());
lines.push(format!(
"**cargo check MUST pass** for: {}",
task.verification
.cargo_check_crates
.iter()
.map(|c| format!("`{c}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !task.verification.cargo_test_crates.is_empty() {
lines.push(format!(
"**cargo test MUST pass** for: {}",
task.verification
.cargo_test_crates
.iter()
.map(|c| format!("`{c}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if let Some(n) = task.verification.test_count_min {
if n > 0 {
lines.push(format!("**minimum test count:** {n}"));
}
}
if !task.output.report_fields_required.is_empty() {
lines.push(String::new());
lines.push(format!(
"**report MUST include fields:** {}",
task.output
.report_fields_required
.iter()
.map(|f| format!("`{f}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if lines.len() == 1 {
return String::new();
}
lines.join("\n")
}
fn load_capability_text(kit_root: &Path, cap_name: &str) -> Result<String> {
let (category, slug) = split_cap_name(cap_name)?;
let path = kit_root