use super::store::load_pack;
use super::types::HarnessPack;
use crate::skills::SkillManifest;
use std::collections::BTreeSet;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct HarnessApplyResult {
pub packs: Vec<HarnessPack>,
pub max_auto_continue_turns: Option<u32>,
pub token_budget: Option<i64>,
pub allow_tools: Option<Vec<String>>,
pub harness_card: String,
}
pub fn effective_allow_tools_for_pack(pack: &HarnessPack) -> Option<Vec<String>> {
let node = pack.entry_node()?;
if node.allow_tools.is_empty() {
return None;
}
let mut tools = node.allow_tools.clone();
tools.sort();
tools.dedup();
Some(tools)
}
pub fn merge_allow_tools(lists: &[Vec<String>]) -> Option<Vec<String>> {
let non_empty: Vec<&Vec<String>> = lists.iter().filter(|l| !l.is_empty()).collect();
if non_empty.is_empty() {
return None;
}
let mut set: BTreeSet<String> = non_empty[0].iter().cloned().collect();
for list in non_empty.iter().skip(1) {
set.retain(|t| list.iter().any(|a| a == t));
}
let mut out: Vec<String> = set.into_iter().collect();
out.sort();
Some(out)
}
pub fn apply_harness_for_skills(
data_dir: &Path,
active_skills: &[SkillManifest],
) -> HarnessApplyResult {
let mut result = HarnessApplyResult::default();
let mut allow_lists: Vec<Vec<String>> = Vec::new();
let mut card_sections: Vec<String> = Vec::new();
for skill in active_skills {
if !skill.harness {
continue;
}
if !skill.allow_tools.is_empty() {
allow_lists.push(skill.allow_tools.clone());
}
let Ok(Some(pack)) = load_pack(data_dir, &skill.id) else {
continue;
};
if pack.loop_spec.max_turns > 0 {
result.max_auto_continue_turns = Some(
result
.max_auto_continue_turns
.map(|m| m.min(pack.loop_spec.max_turns))
.unwrap_or(pack.loop_spec.max_turns),
);
}
if let Some(budget) = pack.loop_spec.token_budget {
if budget > 0 {
result.token_budget =
Some(result.token_budget.map(|b| b.min(budget)).unwrap_or(budget));
}
}
if let Some(tools) = effective_allow_tools_for_pack(&pack) {
allow_lists.push(tools);
}
card_sections.push(render_harness_card_section(&pack));
result.packs.push(pack);
}
result.allow_tools = merge_allow_tools(&allow_lists);
if !card_sections.is_empty() {
result.harness_card = format!(
"=== Active Harness Packs ===\n{}",
card_sections.join("\n\n")
);
}
result
}
fn render_harness_card_section(pack: &HarnessPack) -> String {
let mut lines = Vec::new();
lines.push(format!("## harness `{}`", pack.id));
lines.push(format!("- path: {}", pack.root.display()));
lines.push(format!("- loop.max_turns: {}", pack.loop_spec.max_turns));
if let Some(b) = pack.loop_spec.token_budget {
lines.push(format!("- loop.token_budget: {b}"));
}
lines.push(format!("- loop.stop: [{}]", pack.loop_spec.stop.join(", ")));
if let Some(graph) = &pack.graph {
lines.push(format!("- graph.entry: {}", graph.entry));
let node_ids: Vec<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
lines.push(format!("- graph.nodes: [{}]", node_ids.join(", ")));
if let Some(entry) = pack.entry_node() {
if !entry.allow_tools.is_empty() {
lines.push(format!(
"- graph.entry_allow_tools: [{}]",
entry.allow_tools.join(", ")
));
}
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness_pack::capability::inventory_from_tool_names;
use crate::harness_pack::materialize::{MaterializeOptions, materialize_from_skill};
use crate::skills::{SkillSource, SkillWriteScope};
use std::path::PathBuf;
fn skill(id: &str, allow: &[&str]) -> SkillManifest {
SkillManifest {
id: id.into(),
name: id.into(),
description: None,
version: None,
author: None,
tags: vec![],
requires: vec![],
allow_tools: allow.iter().map(|s| (*s).to_string()).collect(),
deny_tools: vec![],
harness: false,
pool: None,
path: PathBuf::from("x"),
instructions: "do the thing".into(),
source: SkillSource::Store,
scope: SkillWriteScope::User,
}
}
#[test]
fn soft_graph_entry_allow_tools() {
let dir = tempfile::tempdir().unwrap();
let inv = inventory_from_tool_names(
[
"search",
"read_file",
"edit",
"write_file",
"bash",
"tool_search",
],
["browser"],
true,
true,
50,
None::<String>,
None::<String>,
None::<String>,
);
let s = skill("design-loop", &[]);
materialize_from_skill(dir.path(), &s, &inv, MaterializeOptions::default()).unwrap();
let pack = load_pack(dir.path(), "design-loop").unwrap().unwrap();
let tools = effective_allow_tools_for_pack(&pack).unwrap();
assert!(tools.contains(&"search".to_string()) || tools.contains(&"read_file".to_string()));
assert!(tools.iter().any(|t| t == "search" || t == "read_file"));
}
#[test]
fn apply_merges_loop_caps_and_card() {
let dir = tempfile::tempdir().unwrap();
let inv = inventory_from_tool_names(
[
"search",
"read_file",
"edit",
"write_file",
"bash",
"tool_search",
"plan",
],
["browser"],
true,
true,
50,
None::<String>,
None::<String>,
None::<String>,
);
let mut s = skill("design-loop", &[]);
s.harness = true;
let mut opts = MaterializeOptions::default();
opts.default_max_turns = Some(7);
opts.default_token_budget = Some(20_000);
materialize_from_skill(dir.path(), &s, &inv, opts).unwrap();
let mut active_skill = skill("design-loop", &[]);
active_skill.harness = true;
let active = vec![active_skill];
let applied = apply_harness_for_skills(dir.path(), &active);
assert_eq!(applied.packs.len(), 1);
assert_eq!(applied.max_auto_continue_turns, Some(7));
assert_eq!(applied.token_budget, Some(20_000));
assert!(!applied.harness_card.is_empty());
assert!(applied.harness_card.contains("design-loop"));
assert!(applied.allow_tools.is_some());
}
#[test]
fn merge_allow_tools_intersects() {
let a = vec!["a".into(), "b".into(), "c".into()];
let b = vec!["b".into(), "c".into(), "d".into()];
let m = merge_allow_tools(&[a, b]).unwrap();
assert_eq!(m, vec!["b".to_string(), "c".to_string()]);
}
#[test]
fn catalog_create_skill_style_allow_tools_do_not_lock_without_harness_flag() {
let dir = tempfile::tempdir().unwrap();
let create = skill(
"navi-create-skill",
&["skill_list", "skill_save", "load_skill", "read_file"],
);
assert!(!create.harness);
let applied = apply_harness_for_skills(dir.path(), &[create]);
assert!(
applied.allow_tools.is_none(),
"non-harness skill allow_tools must not lock the session: {:?}",
applied.allow_tools
);
assert!(applied.packs.is_empty());
}
#[test]
fn empty_active_list_means_no_soft_allowlist() {
let dir = tempfile::tempdir().unwrap();
let applied = apply_harness_for_skills(dir.path(), &[]);
assert!(applied.allow_tools.is_none());
assert!(applied.packs.is_empty());
assert!(applied.harness_card.is_empty());
}
#[test]
fn harness_flagged_skill_allow_tools_lock_session() {
let dir = tempfile::tempdir().unwrap();
let mut s = skill("design-loop", &["read_file", "search"]);
s.harness = true;
let applied = apply_harness_for_skills(dir.path(), &[s]);
assert_eq!(
applied.allow_tools.as_deref(),
Some(["read_file".to_string(), "search".to_string()].as_slice())
);
}
#[test]
fn pack_entry_allowlist_only_when_skill_passed_as_active() {
let dir = tempfile::tempdir().unwrap();
let inv = inventory_from_tool_names(
[
"search",
"read_file",
"edit",
"write_file",
"bash",
"tool_search",
],
["browser"],
true,
true,
50,
None::<String>,
None::<String>,
None::<String>,
);
let mut s = skill("design-loop", &[]);
s.harness = true;
materialize_from_skill(dir.path(), &s, &inv, MaterializeOptions::default()).unwrap();
let idle = apply_harness_for_skills(dir.path(), &[]);
assert!(idle.allow_tools.is_none());
let non_harness = skill("design-loop", &[]);
assert!(!non_harness.harness);
let skipped = apply_harness_for_skills(dir.path(), &[non_harness]);
assert!(
skipped.allow_tools.is_none(),
"non-harness skill must ignore on-disk pack: {:?}",
skipped.allow_tools
);
let mut active_skill = skill("design-loop", &[]);
active_skill.harness = true;
let active = apply_harness_for_skills(dir.path(), &[active_skill]);
assert!(
active.allow_tools.is_some(),
"active harness skill with pack should soft-lock tools"
);
let tools = active.allow_tools.unwrap();
assert!(
tools.iter().any(|t| t == "search" || t == "read_file"),
"expected explore-oriented entry tools, got {tools:?}"
);
}
}