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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ not per individual commit.
`constraints` skill and the routing rules.
- Orchestrators delegate a step they can't do to the owning specialist and may run independent
parts in parallel, including same-type agents. Parallel write agents must own disjoint files.
- Each Kiro agent's prompt now lists its own skills and tells it to load a matching one before
non-trivial work; orchestrators route skill work to the owning specialist.

### Fixed

Expand Down
5 changes: 5 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ dist/
└── sops/ # one file per SOP
```

Each Kiro CLI v2 and v3 agent's `prompt` ends with a generated `## Your skills`
block listing the agent's declared skills with a rule for when to load one.
Skills whose description starts "MUST USE for every" (today only `constraints`)
are left out, since they claim every task. Claude Code agents get no block.

The filename carries no architecture or OS: the packaged content (agent/skill/SOP
markdown and JSON config, no compiled code) is architecture- and OS-independent within
the Unix family, so there's nothing to disambiguate by encoding a target triple in the
Expand Down
1 change: 1 addition & 0 deletions cli/konductor-rs/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod logging;
pub(crate) mod output;
pub(crate) mod report;
pub(crate) mod schema;
pub(crate) mod skills_server;
pub(crate) mod synth;
mod telemetry;
mod telemetry_hook;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ use super::{push_unique_str, ResourceRewritePass, RewriteContext};
/// key in an agent's mutated JSON to decide whether the V2 grant
/// actually landed for that agent this run (see the "V3/Claude Code
/// permission grant" section below for why that decision matters).
pub(in crate::cli::install) const MCP_SERVER_NAME: &str = "konductor-skills";
/// Defined in `cli::skills_server` so synth's prompt text names the same
/// server.
pub(in crate::cli::install) use crate::cli::skills_server::MCP_SERVER_NAME;
/// `pub(in crate::cli::install)`, not `pub(super)`, for the same
/// re-export-visibility reason as `MCP_SERVER_NAME` above. Also read
/// by `kiro_cli.rs`'s `plan_claude_settings_grant` to predict, at plan
Expand Down Expand Up @@ -795,6 +797,20 @@ mod tests {
dir
}

#[test]
fn grants_are_built_from_the_shared_server_and_tool_names() {
use crate::cli::skills_server::GET_SKILL_TOOL;
assert_eq!(MCP_SERVER_TOOLS_GRANT, format!("@{MCP_SERVER_NAME}"));
for grant in MCP_SERVER_ALLOWED_TOOLS_GRANTS {
assert!(
grant.starts_with(&format!("{MCP_SERVER_TOOLS_GRANT}/")),
"{grant}"
);
}
assert!(MCP_SERVER_ALLOWED_TOOLS_GRANTS
.contains(&format!("{MCP_SERVER_TOOLS_GRANT}/{GET_SKILL_TOOL}").as_str()));
}

/// Empty by construction (a `'static` empty map, so every `ctx()`
/// call site below can borrow it without needing its own live
/// binding) -- exercises the default "no agent has opted in"
Expand Down
8 changes: 8 additions & 0 deletions cli/konductor-rs/src/cli/skills_server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
//
// cli/skills_server.rs: names of the skills MCP server and its skill
// loading tool, shared by install (which wires and grants the server)
// and synth (which tells Kiro agents how to call it).

pub(crate) const MCP_SERVER_NAME: &str = "konductor-skills";
pub(crate) const GET_SKILL_TOOL: &str = "get_skill";
75 changes: 74 additions & 1 deletion cli/konductor-rs/src/cli/synth/kiro_cli_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ use super::content_writers::{write_auxiliary_file, write_skill, write_sop};
use super::model::CanonicalModel;
#[cfg(test)]
use super::model::{AuxiliaryFile, ContextDef, SkillDef, SopDef};
use super::parse_canonical::skill_names_from_dependencies;
use super::parser::KiroCliConfig;
use super::path_safety::reject_unsafe_agent_name;
#[cfg(test)]
use super::path_safety::{
reject_unsafe_auxiliary_relative_path, reject_unsafe_name_segment, reject_unsafe_skill_name,
reject_unsafe_sop_name,
};
use super::skill_index;
use super::staging::stage_content_type;
use super::HarnessTransformer;

Expand Down Expand Up @@ -82,13 +84,20 @@ impl HarnessTransformer for KiroCliV2Transformer {
let Some(kiro) = &agent.client_config.kiro_cli else {
continue;
};
let file = render_agent_file(
let declared: Vec<&str> =
skill_names_from_dependencies(&agent.dependencies.skills).collect();
let prompt = skill_index::kiro_skill_block(&agent.name, &declared, &model.skills)
.map(|block| skill_index::append_block(&agent.config.system_prompt, &block));
let mut file = render_agent_file(
&agent.name,
&agent.config,
kiro,
&agent.dependencies.context.context_names,
&skill_names,
)?;
if let Some(prompt) = &prompt {
file.prompt = prompt;
}
write_agent_file(staging_dir, &agent.name, &file)?;
}
write_sop_scopes_sidecar(staging_dir, &model.agents)?;
Expand Down Expand Up @@ -2024,6 +2033,70 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}

fn skill_with_description(name: &str, description: &str) -> crate::cli::synth::model::SkillDef {
crate::cli::synth::model::SkillDef {
name: name.to_string(),
raw_frontmatter: format!("name: {name}\ndescription: {description}"),
body: "# Body\n".to_string(),
auxiliary_files: Vec::new(),
}
}

fn written_prompt(dir: &Path, agent: &str) -> String {
let path = dir
.join(expected_output_dir())
.join(format!("{agent}.json"));
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
parsed["prompt"].as_str().unwrap().to_string()
}

#[test]
fn transform_appends_declared_skill_index_to_prompt() {
let dir = temp_dir("skill-index");
let model = CanonicalModel {
agents: vec![agent_with_kiro_and_skill_names(
"k-example",
KiroCliConfig::default(),
&["beta", "alpha"],
)],
skills: vec![
skill_with_description("alpha", "Use for alpha work."),
skill_with_description("beta", "Use for beta work."),
skill_with_description("gamma", "Use for gamma work."),
],
..Default::default()
};
KiroCliV2Transformer.transform(&model, &dir).unwrap();

let prompt = written_prompt(&dir, "k-example");
assert!(prompt.starts_with("You are a test agent.\n\n## Your skills\n"));
assert!(prompt.contains("`get_skill`"));
let lines: Vec<&str> = prompt.lines().filter(|l| l.starts_with("- ")).collect();
assert_eq!(
lines,
vec!["- beta: Use for beta work.", "- alpha: Use for alpha work."]
);
assert!(prompt.ends_with("- alpha: Use for alpha work.\n"));

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn transform_leaves_prompt_unchanged_for_agent_without_skills() {
let dir = temp_dir("skill-index-none");
let model = CanonicalModel {
agents: vec![agent_with_kiro("k-example", KiroCliConfig::default())],
skills: vec![skill_with_description("alpha", "Use for alpha work.")],
..Default::default()
};
KiroCliV2Transformer.transform(&model, &dir).unwrap();

assert_eq!(written_prompt(&dir, "k-example"), "You are a test agent.");

let _ = fs::remove_dir_all(&dir);
}

/// An agent with no declared `skillNames` is omitted from the
/// sidecar entirely -- absent from the map and an empty array both
/// mean "do not pass `--skill-name-filter`" to the install-side
Expand Down
75 changes: 74 additions & 1 deletion cli/konductor-rs/src/cli/synth/kiro_cli_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ use super::kiro_cli_v2::{
SOPS_CONTENT_TYPE_DIR,
};
use super::model::CanonicalModel;
use super::parse_canonical::skill_names_from_dependencies;
use super::parser::KiroCliConfig;
use super::path_safety::reject_unsafe_agent_name;
use super::skill_index;
use super::staging::stage_content_type;
use super::HarnessTransformer;

Expand Down Expand Up @@ -373,13 +375,20 @@ impl HarnessTransformer for KiroCliV3Transformer {
let Some(kiro) = &agent.client_config.kiro_cli else {
continue;
};
let file = render_agent_file(
let declared: Vec<&str> =
skill_names_from_dependencies(&agent.dependencies.skills).collect();
let prompt = skill_index::kiro_skill_block(&agent.name, &declared, &model.skills)
.map(|block| skill_index::append_block(&agent.config.system_prompt, &block));
let mut file = render_agent_file(
&agent.name,
&agent.config,
kiro,
&agent.dependencies.context.context_names,
&skill_names,
)?;
if let Some(prompt) = &prompt {
file.prompt = prompt;
}
write_agent_file(staging_dir, &agent.name, &file)?;
}
write_sop_scopes_sidecar(staging_dir, &model.agents)?;
Expand Down Expand Up @@ -2530,6 +2539,70 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}

fn skill_with_description(name: &str, description: &str) -> crate::cli::synth::model::SkillDef {
crate::cli::synth::model::SkillDef {
name: name.to_string(),
raw_frontmatter: format!("name: {name}\ndescription: {description}"),
body: "# Body\n".to_string(),
auxiliary_files: Vec::new(),
}
}

fn written_prompt(dir: &Path, agent: &str) -> String {
let path = dir
.join(expected_output_dir())
.join(format!("{agent}.json"));
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
parsed["prompt"].as_str().unwrap().to_string()
}

#[test]
fn transform_appends_declared_skill_index_to_prompt() {
let dir = temp_dir("skill-index");
let model = CanonicalModel {
agents: vec![agent_with_kiro_and_skill_names(
"k-example",
KiroCliConfig::default(),
&["beta", "alpha"],
)],
skills: vec![
skill_with_description("alpha", "Use for alpha work."),
skill_with_description("beta", "Use for beta work."),
skill_with_description("gamma", "Use for gamma work."),
],
..Default::default()
};
KiroCliV3Transformer.transform(&model, &dir).unwrap();

let prompt = written_prompt(&dir, "k-example");
assert!(prompt.starts_with("You are a test agent.\n\n## Your skills\n"));
assert!(prompt.contains("`get_skill`"));
let lines: Vec<&str> = prompt.lines().filter(|l| l.starts_with("- ")).collect();
assert_eq!(
lines,
vec!["- beta: Use for beta work.", "- alpha: Use for alpha work."]
);
assert!(prompt.ends_with("- alpha: Use for alpha work.\n"));

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn transform_leaves_prompt_unchanged_for_agent_without_skills() {
let dir = temp_dir("skill-index-none");
let model = CanonicalModel {
agents: vec![agent_with_kiro("k-example", KiroCliConfig::default())],
skills: vec![skill_with_description("alpha", "Use for alpha work.")],
..Default::default()
};
KiroCliV3Transformer.transform(&model, &dir).unwrap();

assert_eq!(written_prompt(&dir, "k-example"), "You are a test agent.");

let _ = fs::remove_dir_all(&dir);
}

/// An agent with no declared `skillNames` is omitted from the
/// sidecar entirely -- mirrors
/// `transform_omits_agents_with_no_sop_names_from_sop_scopes_sidecar`
Expand Down
1 change: 1 addition & 0 deletions cli/konductor-rs/src/cli/synth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub(crate) mod package;
// path-containment predicate.
pub(crate) mod path_safety;
mod sidecar;
mod skill_index;
mod staging;

#[allow(unused_imports)]
Expand Down
5 changes: 5 additions & 0 deletions cli/konductor-rs/src/cli/synth/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ pub struct CanonicalModel {
pub context: Vec<ContextDef>,
}

/// Orchestrators are the `konductor*` agents.
pub(crate) fn is_orchestrator(agent_name: &str) -> bool {
agent_name.starts_with("konductor")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
3 changes: 3 additions & 0 deletions cli/konductor-rs/src/cli/synth/parse_canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ pub fn parse_canonical(source_dir: &Path) -> Result<CanonicalModel, ParseError>
for warning in check_skill_scope_consistency(&agents) {
eprintln!("konductor: warning: {warning}");
}
for warning in super::skill_index::every_task_skill_warnings(&skills) {
eprintln!("konductor: warning: {warning}");
}

Ok(CanonicalModel {
agents,
Expand Down
Loading
Loading