docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -77,7 +77,7 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// ── From definition ──────────────────────────────────────────────
|
||||
// From definition
|
||||
|
||||
/// Agent name (unique identifier).
|
||||
pub fn name(&self) -> &str {
|
||||
@@ -99,14 +99,12 @@ impl Agent {
|
||||
&self.definition.permission_mode
|
||||
}
|
||||
|
||||
/// Completion requirement, if any.
|
||||
pub fn completion_requirement(&self) -> Option<&CompletionRequirement> {
|
||||
self.definition.completion_requirement.as_ref()
|
||||
}
|
||||
|
||||
// ── Session-level ────────────────────────────────────────────────
|
||||
// Session-level
|
||||
|
||||
/// The rendered system prompt.
|
||||
pub fn system_prompt(&self) -> &str {
|
||||
&self.system_prompt
|
||||
}
|
||||
@@ -123,12 +121,10 @@ impl Agent {
|
||||
&self.tool_bridge
|
||||
}
|
||||
|
||||
/// Compaction policy.
|
||||
pub fn compaction_policy(&self) -> &CompactionPolicy {
|
||||
&self.compaction_policy
|
||||
}
|
||||
|
||||
/// Reminder policy.
|
||||
pub fn reminder_policy(&self) -> &ReminderPolicy {
|
||||
&self.reminder_policy
|
||||
}
|
||||
@@ -216,7 +212,7 @@ impl Agent {
|
||||
/// Does NOT rebuild the tool registry or re-render prompts.
|
||||
/// Used for mid-session mode switching.
|
||||
pub async fn update_policies_from_definition(&self, _def: &AgentDefinition) {
|
||||
// TODO: completion requirements and retry configs are now part of
|
||||
// TODO: completion requirements and retry configs are part of
|
||||
// ToolServerConfig and handled at registry finalization time.
|
||||
// Mid-session policy updates are not yet supported in the new architecture.
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
//! Compaction policy — threshold, model, and memory flush configuration.
|
||||
|
||||
/// Session-level compaction policy.
|
||||
///
|
||||
/// Controls when and how the session's conversation is compacted
|
||||
/// to free up context window space, and whether a memory flush
|
||||
/// runs before each compaction.
|
||||
/// Controls when and how the session's conversation is compacted to free up
|
||||
/// context window space, and whether a memory flush runs before each compaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionPolicy {
|
||||
/// Percentage of context window that triggers auto-compaction.
|
||||
/// E.g., 85 means compact when 85% of the context window is used.
|
||||
pub auto_compact_threshold_percent: u32,
|
||||
|
||||
/// Model to use for generating the compaction summary.
|
||||
/// None = use the session's current model.
|
||||
/// `None` uses the session's current model.
|
||||
pub compact_model: Option<String>,
|
||||
|
||||
/// Whether to run a memory flush turn before each compaction.
|
||||
/// When enabled, the session actor asks the model to summarize
|
||||
/// important information from the conversation before it's compacted.
|
||||
/// Requires the memory system to be enabled.
|
||||
/// Run a memory flush turn before each compaction: the session actor asks
|
||||
/// the model to summarize important information from the conversation
|
||||
/// before it is discarded. Requires the memory system to be enabled.
|
||||
pub memory_flush_enabled: bool,
|
||||
|
||||
/// Per-compaction wall-clock budget (seconds); a generation exceeding it is
|
||||
@@ -27,9 +21,9 @@ pub struct CompactionPolicy {
|
||||
|
||||
/// Prefire two-pass compaction: when usage approaches the threshold,
|
||||
/// speculatively summarize the history prefix in the background (pass 1);
|
||||
/// at compaction, summarize NOTE₁ + the recent tail (pass 2). Resolved from
|
||||
/// config (`two_pass_compaction` flag) at session build; `false` keeps the
|
||||
/// legacy single-pass path. Default `false` (real sessions set it from config).
|
||||
/// at compaction, summarize NOTE₁ + the recent tail (pass 2). `false`
|
||||
/// selects the single-pass path. Real sessions resolve this from the
|
||||
/// `two_pass_compaction` config flag at session build.
|
||||
pub two_pass_enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1381,10 +1381,10 @@ impl AgentDefinition {
|
||||
///
|
||||
/// Used by the runtime turn-end TodoGate to gate firing on sessions
|
||||
/// whose prompt actually references the rules the gate's reminder
|
||||
/// text invokes. The block has been removed from every built-in
|
||||
/// template, so this returns `false` unconditionally. Kept as a
|
||||
/// helper so the gate's call-site stays stable in case the block
|
||||
/// is reintroduced behind a future flag.
|
||||
/// text invokes. No built-in template carries the block, so this
|
||||
/// returns `false` unconditionally. Kept as a helper so the gate's
|
||||
/// call-site stays stable in case the block is reintroduced behind
|
||||
/// a future flag.
|
||||
pub fn carries_task_completion_discipline(
|
||||
&self,
|
||||
_audience: crate::prompt::context::PromptAudience,
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn project_agent_dirs_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
crate::repo::existing_subdirs_along(chain_dirs, PROJECT_AGENT_SUBDIRS)
|
||||
}
|
||||
|
||||
// ── Subagent entry types ─────────────────────────────────────────────
|
||||
// Subagent entry types
|
||||
|
||||
/// A subagent entry for the Task tool description and spawn-time validation.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -61,7 +61,7 @@ pub enum SubagentSource {
|
||||
UserDefined { scope: AgentScope },
|
||||
}
|
||||
|
||||
// ── all_subagents ────────────────────────────────────────────────────
|
||||
// all_subagents
|
||||
|
||||
/// Build the complete list of enabled subagents.
|
||||
///
|
||||
@@ -102,7 +102,6 @@ fn merge_subagents(
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Seed with built-in subagents
|
||||
let mut entries: Vec<SubagentEntry> = BuiltinAgentName::subagent_variants()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
@@ -117,7 +116,6 @@ fn merge_subagents(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Merge in discovered user-defined agents.
|
||||
//
|
||||
// IMPORTANT: Only project-level agents can shadow built-ins. This matches
|
||||
// the runtime spawn precedence in by_name_in_cwd():
|
||||
@@ -173,7 +171,6 @@ fn merge_subagents(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Filter by toggle (omitted = enabled)
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|e| toggle.get(&e.name).copied().unwrap_or(true))
|
||||
@@ -359,7 +356,7 @@ fn source_from_agent_def(def: &AgentDefinition) -> ConfigSource {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin-aware variants ─────────────────────────────────────────────
|
||||
// Plugin-aware variants
|
||||
|
||||
/// Build the complete list of enabled subagents, including plugin agents.
|
||||
pub fn all_subagents_with_plugins(
|
||||
@@ -1031,7 +1028,7 @@ mod tests {
|
||||
assert_eq!(def.scope, AgentScope::BuiltIn);
|
||||
}
|
||||
|
||||
// ── all_subagents / merge_subagents tests ───────────────────────
|
||||
// all_subagents / merge_subagents tests
|
||||
|
||||
/// Helper: build a minimal synthetic AgentDefinition for testing merge logic.
|
||||
fn synthetic_agent(name: &str, desc: &str, scope: AgentScope) -> AgentDefinition {
|
||||
@@ -1110,7 +1107,8 @@ mod tests {
|
||||
AgentScope::Project,
|
||||
)];
|
||||
let entries = merge_subagents(discovered, &HashMap::new());
|
||||
assert_eq!(entries.len(), 4); // 3 built-ins + 1 user
|
||||
// 3 built-ins + 1 user
|
||||
assert_eq!(entries.len(), 4);
|
||||
let cr = entries.iter().find(|e| e.name == "code-reviewer").unwrap();
|
||||
assert_eq!(cr.description, "Reviews code");
|
||||
assert_eq!(
|
||||
@@ -1131,7 +1129,8 @@ mod tests {
|
||||
)];
|
||||
let toggle = HashMap::from([("code-reviewer".to_string(), false)]);
|
||||
let entries = merge_subagents(discovered, &toggle);
|
||||
assert_eq!(entries.len(), 3); // only built-ins
|
||||
// only built-ins
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert!(entries.iter().all(|e| e.name != "code-reviewer"));
|
||||
}
|
||||
|
||||
@@ -1143,7 +1142,8 @@ mod tests {
|
||||
AgentScope::Project,
|
||||
)];
|
||||
let entries = merge_subagents(discovered, &HashMap::new());
|
||||
assert_eq!(entries.len(), 3); // still 3 — replaced, not appended
|
||||
// still 3 — replaced, not appended
|
||||
assert_eq!(entries.len(), 3);
|
||||
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
|
||||
assert_eq!(explore.description, "Custom explore agent");
|
||||
assert_eq!(
|
||||
@@ -1180,7 +1180,8 @@ mod tests {
|
||||
AgentScope::User,
|
||||
)];
|
||||
let entries = merge_subagents(discovered, &HashMap::new());
|
||||
assert_eq!(entries.len(), 3); // still 3 built-ins
|
||||
// still 3 built-ins
|
||||
assert_eq!(entries.len(), 3);
|
||||
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
|
||||
// Should still be the built-in, not the user-level agent
|
||||
assert!(
|
||||
@@ -1215,7 +1216,8 @@ mod tests {
|
||||
AgentScope::User,
|
||||
)];
|
||||
let entries = merge_subagents(discovered, &HashMap::new());
|
||||
assert_eq!(entries.len(), 4); // 3 built-ins + 1 user
|
||||
// 3 built-ins + 1 user
|
||||
assert_eq!(entries.len(), 4);
|
||||
// Verify ordering: built-ins first, then user
|
||||
assert!(matches!(&entries[0].source, SubagentSource::Builtin(_)));
|
||||
assert!(matches!(&entries[1].source, SubagentSource::Builtin(_)));
|
||||
@@ -1262,7 +1264,8 @@ mod tests {
|
||||
// Simulate: discover() skips invalid files (returns empty for that file).
|
||||
// So if a user's explore.md is invalid, discover() won't include it,
|
||||
// and the built-in explore remains.
|
||||
let discovered = vec![]; // no valid user agents discovered
|
||||
// no valid user agents discovered
|
||||
let discovered = vec![];
|
||||
let entries = merge_subagents(discovered, &HashMap::new());
|
||||
assert_eq!(entries.len(), 3);
|
||||
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
//! Error types for agent construction.
|
||||
|
||||
/// Errors that can occur during Agent construction.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AgentBuildError {
|
||||
/// Failed to parse the agent definition file (bad YAML frontmatter,
|
||||
/// missing closing `---`, or invalid Markdown structure).
|
||||
/// Bad YAML frontmatter, a missing closing `---`, or invalid Markdown
|
||||
/// structure in the definition file.
|
||||
#[error("failed to parse agent definition: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
/// Required fields are missing from the definition (name, description).
|
||||
#[error("missing required field in agent definition: {0}")]
|
||||
MissingField(String),
|
||||
|
||||
/// A tool name override references a tool that doesn't exist in the
|
||||
/// registry (typo in the definition's `toolNameOverrides`).
|
||||
/// Usually a typo in the definition's `toolNameOverrides`.
|
||||
#[error("tool name override references nonexistent tool '{0}'")]
|
||||
UnknownToolOverride(String),
|
||||
|
||||
/// IO error during AGENTS.md or skills discovery.
|
||||
#[error("IO error during agent construction: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// MiniJinja template rendering failed (extend or full mode).
|
||||
/// Includes line numbers and context from the template.
|
||||
/// Carries template line numbers and surrounding context.
|
||||
#[error("template rendering error: {0}")]
|
||||
MiniJinjaError(#[from] minijinja::Error),
|
||||
|
||||
/// Tool registry error (e.g., unsatisfied requirements during finalization).
|
||||
#[error("tool error: {0}")]
|
||||
ToolError(String),
|
||||
|
||||
/// A configuration value is present but invalid (e.g. `max_turns = 0`).
|
||||
#[error("invalid configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Agent builder, definition parsing, and system prompt assembly.
|
||||
//!
|
||||
//! This crate extracts a first-class `Agent` type from `kigi-shell`.
|
||||
//! An `Agent` bundles tools, system prompt, system-reminder policy,
|
||||
//! compaction policy, and model configuration into a single, portable
|
||||
//! object that any host can consume.
|
||||
|
||||
@@ -21,7 +21,7 @@ use sha2::{Digest, Sha256};
|
||||
use super::manifest::{ManifestLoadResult, PluginManifest, load_manifest, name_from_dirname};
|
||||
use super::trust::TrustStore;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────
|
||||
// Public types
|
||||
|
||||
/// Where a plugin was discovered from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -204,12 +204,12 @@ impl DiscoveryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discovery entry point ─────────────────────────────────────────────
|
||||
// Discovery entry point
|
||||
|
||||
/// User plugin directories in priority order: `$KIGI_SHARE_DIR/plugins` then
|
||||
/// `~/.claude/plugins`.
|
||||
///
|
||||
/// Unlike agent discovery, plugins are intentionally NOT discovered from a
|
||||
/// Unlike agent discovery, plugins are deliberately NOT discovered from a
|
||||
/// legacy `~/.kigi/plugins`: plugin trust, persisted plugin-data, and install
|
||||
/// paths all resolve under `kigi_home()`, so a plugin scanned from the legacy
|
||||
/// tree would appear untrusted and lose its persisted state. Keeping plugins on
|
||||
@@ -483,7 +483,7 @@ pub fn discover_plugins(
|
||||
candidates
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
|
||||
/// Scan a plugins parent directory (e.g. `~/.kigi/plugins/`) and collect
|
||||
/// each subdirectory as a plugin candidate.
|
||||
@@ -510,7 +510,8 @@ fn scan_plugin_dir(
|
||||
|
||||
let mut subdirs: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir()) // follows symlinks
|
||||
// follows symlinks
|
||||
.filter(|e| e.path().is_dir())
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
@@ -787,7 +788,7 @@ fn resolve_name_conflicts(candidates: &mut Vec<DiscoveredPlugin>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compat installed_plugins.json types ───────────────────────────────
|
||||
// Compat installed_plugins.json types
|
||||
|
||||
/// Compat `installed_plugins.json` format.
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -1351,7 +1352,7 @@ mod tests {
|
||||
let parts: Vec<&str> = id.0.split('/').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert_eq!(parts[0], "user");
|
||||
assert_eq!(parts[1].len(), 8); // 8 hex chars
|
||||
assert_eq!(parts[1].len(), 8);
|
||||
assert_eq!(parts[2], "my-plugin");
|
||||
}
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ pub struct UpdateResult {
|
||||
|
||||
/// Status of an update attempt.
|
||||
pub enum UpdateStatus {
|
||||
/// Repo was updated successfully.
|
||||
/// Repo updated successfully.
|
||||
Updated(UpdateResult),
|
||||
/// Repo is pinned to a tag or commit — no automatic update.
|
||||
Pinned { ref_name: String },
|
||||
@@ -633,7 +633,7 @@ pub fn update_repo(repo_key: &str, repo: &InstalledRepo) -> Result<UpdateStatus,
|
||||
let new_commit = read_head_commit(repo_path);
|
||||
let changed = old_commit.as_deref() != new_commit.as_deref();
|
||||
|
||||
// Re-discover plugins (new ones may have been added)
|
||||
// Re-discover plugins: the pull may bring new ones
|
||||
let plugins = discover_plugins_in_dir(repo_path, subdir.as_deref())?;
|
||||
|
||||
Ok(UpdateStatus::Updated(UpdateResult {
|
||||
|
||||
@@ -296,7 +296,8 @@ mod tests {
|
||||
fn prefilter_handles_invalid_json() {
|
||||
let json = "not valid json{";
|
||||
let (filtered, skipped) = prefilter_unsupported_events(json);
|
||||
assert_eq!(filtered, json); // returned as-is
|
||||
// returned as-is
|
||||
assert_eq!(filtered, json);
|
||||
assert!(skipped.is_empty());
|
||||
}
|
||||
|
||||
@@ -500,7 +501,7 @@ mod tests {
|
||||
/// reference resolves to the plugin root exactly once, and the result
|
||||
/// contains no leftover `$` placeholders. This is the contract the
|
||||
/// hooks_adapter has long held, and it must continue to hold
|
||||
/// now that `parse_hook_file` itself does an env-expansion pass with
|
||||
/// because `parse_hook_file` itself does an env-expansion pass with
|
||||
/// the per-hook `extra_env`. The first pass (in `parse_hook_file`)
|
||||
/// runs against an EMPTY `extra_env` for plugin hooks (the adapter
|
||||
/// only fills it in afterwards), so the placeholder survives that
|
||||
|
||||
@@ -297,7 +297,7 @@ impl InstallRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
// Errors
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InstallError {
|
||||
@@ -323,7 +323,7 @@ pub enum InstallError {
|
||||
InstallFailed { detail: String },
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -154,7 +154,7 @@ pub struct PluginManifest {
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
// ── Component path overrides (supplement convention dirs) ──────
|
||||
// Component path overrides (supplement convention dirs)
|
||||
#[serde(default)]
|
||||
pub skills: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
@@ -247,7 +247,7 @@ impl PluginManifest {
|
||||
|
||||
/// Log informational messages about manifest features.
|
||||
///
|
||||
/// Called during discovery. Inline hooks and MCP servers are now
|
||||
/// Called during discovery. Inline hooks and MCP servers are
|
||||
/// fully supported; this method logs when they are detected.
|
||||
pub fn warn_unsupported_features(&self, plugin_name: &str) {
|
||||
if self.inline_hooks().is_some() {
|
||||
@@ -287,7 +287,7 @@ fn resolve_dirs(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────────
|
||||
// Manifest loading
|
||||
|
||||
/// Manifest search order within a plugin directory.
|
||||
const MANIFEST_PATHS: &[&str] = &[
|
||||
@@ -375,7 +375,7 @@ pub fn normalize_inline_mcp_servers(value: &serde_json::Value) -> serde_json::Va
|
||||
serde_json::json!({ "mcpServers": inner })
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
// Errors
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
@@ -530,7 +530,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/---")),
|
||||
None // all hyphens after trim
|
||||
// all hyphens after trim
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn load_enabled_disabled_plugins(path: &Path) -> (Vec<String>, Vec<String>)
|
||||
parse_enabled_disabled_plugins(&json)
|
||||
}
|
||||
|
||||
// ── Compat known_marketplaces.json ────────────────────────────────────
|
||||
// Compat known_marketplaces.json
|
||||
|
||||
/// Entry in `~/.claude/plugins/known_marketplaces.json`.
|
||||
#[derive(serde::Deserialize)]
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
//! Plugin system — discover, load, and manage plugins (including compat layouts).
|
||||
//! Plugin discovery, loading, and registry.
|
||||
//!
|
||||
//! A plugin is a self-contained directory that bundles skills, agents,
|
||||
//! MCP server configs, and hooks into a namespaced unit. Plugins can
|
||||
//! MCP server configs, and hooks into a namespaced unit. Plugins can
|
||||
//! live under `~/.kigi/plugins/`, `.kigi/plugins/` (project-level),
|
||||
//! or be passed via `--plugin-dir` on the CLI.
|
||||
//!
|
||||
//! This module handles:
|
||||
//! - `manifest` — parsing `plugin.json` manifests
|
||||
//! - `discovery` — scanning the filesystem for plugin directories
|
||||
//! - `trust` — project-plugin trust management
|
||||
//! - `registry` — in-memory registry of active plugins
|
||||
|
||||
pub mod discovery;
|
||||
pub mod git_install;
|
||||
|
||||
@@ -301,7 +301,7 @@ impl PluginRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared handle for cross-thread reload ─────────────────────────────
|
||||
// Shared handle for cross-thread reload
|
||||
|
||||
/// Thread-safe handle for plugin registry lifecycle.
|
||||
///
|
||||
@@ -456,7 +456,7 @@ impl SharedPluginRegistryHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component counting helpers ────────────────────────────────────────
|
||||
// Component counting helpers
|
||||
|
||||
/// Collect the SKILL.md paths that load from the given skill dirs.
|
||||
///
|
||||
@@ -793,9 +793,11 @@ mod tests {
|
||||
&["enabled-plugin".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(reg.len(), 2); // Both in registry
|
||||
// Both in registry
|
||||
assert_eq!(reg.len(), 2);
|
||||
let active = reg.active_plugins();
|
||||
assert_eq!(active.len(), 1); // Only enabled one is active
|
||||
// Only enabled one is active
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].name, "enabled-plugin");
|
||||
|
||||
// Disabled one is in list but marked disabled
|
||||
@@ -876,9 +878,12 @@ mod tests {
|
||||
|
||||
let reg = PluginRegistry::from_discovered(plugins, &[], &[]);
|
||||
let list = reg.list();
|
||||
assert_eq!(list[0].name, "alpha"); // CliOverride = 0
|
||||
assert_eq!(list[1].name, "beta"); // Project = 1
|
||||
assert_eq!(list[2].name, "zebra"); // User = 2
|
||||
// CliOverride = 0
|
||||
assert_eq!(list[0].name, "alpha");
|
||||
// Project = 1
|
||||
assert_eq!(list[1].name, "beta");
|
||||
// User = 2
|
||||
assert_eq!(list[2].name, "zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -935,7 +940,7 @@ mod tests {
|
||||
assert_eq!(reg.mcp_server_owner("my-server"), Some("mcp-plugin"));
|
||||
}
|
||||
|
||||
// ── Combined disabled + untrusted scenarios ─────────────────
|
||||
// Combined disabled + untrusted scenarios
|
||||
|
||||
#[test]
|
||||
fn disabled_project_plugin_excluded_from_active_and_enabled() {
|
||||
@@ -961,7 +966,7 @@ mod tests {
|
||||
|
||||
let bad = reg.get("bad-plugin").unwrap();
|
||||
assert!(!bad.enabled);
|
||||
// trusted is now propagated from discovery (was false for Project scope)
|
||||
// trusted is propagated from discovery (was false for Project scope)
|
||||
assert!(!bad.trusted);
|
||||
}
|
||||
|
||||
@@ -1166,7 +1171,7 @@ mod tests {
|
||||
assert_eq!(config.disabled.len(), 2);
|
||||
}
|
||||
|
||||
// ── Security: trust propagation from discovery ──────────────
|
||||
// Security: trust propagation from discovery
|
||||
|
||||
#[test]
|
||||
fn untrusted_project_plugin_excluded_from_active_even_when_enabled() {
|
||||
@@ -1174,12 +1179,14 @@ mod tests {
|
||||
// pre-populated enabledPlugins) but NOT trusted. It must NOT
|
||||
// appear in active_plugins() so its hooks never fire.
|
||||
let plugins = vec![
|
||||
make_discovered("malicious", PluginScope::Project, false), // untrusted
|
||||
// untrusted
|
||||
make_discovered("malicious", PluginScope::Project, false),
|
||||
];
|
||||
let reg = PluginRegistry::from_discovered(
|
||||
plugins,
|
||||
&[],
|
||||
&["malicious".to_string()], // attacker got it into enabled list
|
||||
// attacker got it into enabled list
|
||||
&["malicious".to_string()],
|
||||
);
|
||||
|
||||
// Plugin is enabled but not trusted
|
||||
|
||||
@@ -129,7 +129,8 @@ impl TrustStore {
|
||||
})?;
|
||||
|
||||
if !self.trusted.remove(&canonical) {
|
||||
return Ok(()); // wasn't trusted
|
||||
// wasn't trusted
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Rewrite the entire file without the revoked path
|
||||
@@ -176,7 +177,7 @@ impl TrustStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────
|
||||
// Internal
|
||||
|
||||
fn read_trust_file(path: &Path) -> HashSet<PathBuf> {
|
||||
let file = match std::fs::File::open(path) {
|
||||
@@ -208,7 +209,7 @@ impl TrustStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
// Errors
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TrustError {
|
||||
@@ -320,7 +321,8 @@ mod tests {
|
||||
// This test checks the logic but can't easily mock $HOME.
|
||||
// We verify the function exists and returns a boolean.
|
||||
let result = TrustStore::is_config_path_auto_trusted(Path::new("/nonexistent/path"));
|
||||
assert!(!result); // nonexistent path can't be canonicalized
|
||||
// nonexistent path can't be canonicalized
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -239,7 +239,7 @@ mod tests {
|
||||
git2::Repository::init(path).unwrap();
|
||||
}
|
||||
|
||||
// ── find_agent_files unit tests ─────────────────────────────────
|
||||
// find_agent_files unit tests
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_finds_agents_md() {
|
||||
@@ -320,7 +320,7 @@ mod tests {
|
||||
assert!(files[1].to_string_lossy().contains("style.md"));
|
||||
}
|
||||
|
||||
// ── format_agents_md_section tests ──────────────────────────────
|
||||
// format_agents_md_section tests
|
||||
|
||||
#[test]
|
||||
fn format_agents_md_section_empty_returns_none() {
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature 2: Workspace user AGENTS.md via read_agents_config ───
|
||||
// Feature 2: Workspace user AGENTS.md via read_agents_config
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_includes_workspace_user_agents_md() {
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
assert!(!section.contains("globs:"));
|
||||
}
|
||||
|
||||
// ── .claude/CLAUDE.md integration tests ─────────────────────────
|
||||
// .claude/CLAUDE.md integration tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_discovers_claude_subdir_claude_md() {
|
||||
|
||||
@@ -224,9 +224,9 @@ impl PromptContext {
|
||||
}
|
||||
/// Format the personas section content.
|
||||
///
|
||||
/// Always returns `None` — the `persona` parameter has been removed
|
||||
/// from the task tool input, so persona summaries are no longer
|
||||
/// injected into the conversation.
|
||||
/// Always returns `None` — the task tool input carries no `persona`
|
||||
/// parameter, so persona summaries are never injected into the
|
||||
/// conversation.
|
||||
pub fn format_personas_section(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn build_gitignore(repo_root: Option<&Path>) -> Option<Gitignore> {
|
||||
// No repo root → no gitignore rules to apply.
|
||||
let root = repo_root?;
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
|
||||
|
||||
@@ -726,7 +726,7 @@ mod tests {
|
||||
fs::write(dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
// ── Server-synced skills (injected server_skill_dirs) ────────────────
|
||||
// Server-synced skills (injected server_skill_dirs)
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_skills_discovered_and_shadowed_by_local() {
|
||||
@@ -826,7 +826,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature 3: Recursive skill reading ──────────────────────────────
|
||||
// Feature 3: Recursive skill reading
|
||||
|
||||
#[test]
|
||||
fn find_skill_paths_flat_layout() {
|
||||
@@ -960,7 +960,7 @@ mod tests {
|
||||
assert!(path_strs.iter().any(|p| p.contains("child/SKILL.md")));
|
||||
}
|
||||
|
||||
// ── extract_first_paragraph ──────────────────────────────────────
|
||||
// extract_first_paragraph
|
||||
|
||||
#[test]
|
||||
fn first_paragraph_simple() {
|
||||
@@ -1001,7 +1001,7 @@ mod tests {
|
||||
assert!(extract_first_paragraph(body).is_none());
|
||||
}
|
||||
|
||||
// ── UTF-8 safe body truncation ──────────────────────────────────
|
||||
// UTF-8 safe body truncation
|
||||
|
||||
#[test]
|
||||
fn description_fallback_does_not_panic_on_multibyte_boundary() {
|
||||
@@ -1012,10 +1012,12 @@ mod tests {
|
||||
// Strategy: fill with ASCII up to near the limit, then pack 4-byte
|
||||
// emoji right at the boundary.
|
||||
let prefix = "# Heading\n\n";
|
||||
let filler_len = MAX_BODY_PEEK_BYTES - prefix.len() - 4; // leave room for emoji at boundary
|
||||
// leave room for emoji at boundary
|
||||
let filler_len = MAX_BODY_PEEK_BYTES - prefix.len() - 4;
|
||||
let filler = "a".repeat(filler_len);
|
||||
// Each emoji is 4 bytes. Place several so one straddles the 2048 mark.
|
||||
let emoji_run = "\u{1F600}".repeat(10); // 40 bytes of emoji
|
||||
// 40 bytes of emoji
|
||||
let emoji_run = "\u{1F600}".repeat(10);
|
||||
let body = format!("{prefix}{filler}{emoji_run}");
|
||||
assert!(body.len() > MAX_BODY_PEEK_BYTES, "body must exceed limit");
|
||||
|
||||
@@ -1041,7 +1043,8 @@ mod tests {
|
||||
|
||||
// Body (after frontmatter): heading + paragraph with multibyte chars
|
||||
// exceeding 2048 bytes.
|
||||
let long_paragraph = "\u{00E9}".repeat(MAX_BODY_PEEK_BYTES); // 2-byte chars
|
||||
// 2-byte chars
|
||||
let long_paragraph = "\u{00E9}".repeat(MAX_BODY_PEEK_BYTES);
|
||||
let content = format!("---\nname: emoji-skill\n---\n# Test\n\n{long_paragraph}\n");
|
||||
fs::write(skill_dir.join("SKILL.md"), &content).unwrap();
|
||||
|
||||
@@ -1055,7 +1058,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Frontmatter parsing (existing coverage + regression) ─────────
|
||||
// Frontmatter parsing (existing coverage + regression)
|
||||
|
||||
#[test]
|
||||
fn parse_valid_frontmatter() {
|
||||
@@ -1122,7 +1125,7 @@ mod tests {
|
||||
assert!(parsed.effort.is_none());
|
||||
}
|
||||
|
||||
// ── agentskills.io spec parity ────────────────────────────────
|
||||
// agentskills.io spec parity
|
||||
|
||||
#[test]
|
||||
fn parse_license_and_compatibility() {
|
||||
@@ -1296,7 +1299,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Feature 1: Workspace user skills via list_skills ─────────────
|
||||
// Feature 1: Workspace user skills via list_skills
|
||||
|
||||
/// Helper: initialize a bare git repo at `path` so git2::Repository::discover works.
|
||||
fn init_git_repo(path: &Path) {
|
||||
@@ -1423,7 +1426,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── collect_config_skills ────────────────────────────────────────
|
||||
// collect_config_skills
|
||||
|
||||
#[test]
|
||||
fn collect_config_skills_from_directory() {
|
||||
@@ -1530,7 +1533,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── filter_skills ────────────────────────────────────────────────
|
||||
// filter_skills
|
||||
|
||||
fn make_skill(name: &str, path: &str) -> SkillInfo {
|
||||
SkillInfo {
|
||||
@@ -1622,7 +1625,7 @@ mod tests {
|
||||
assert_eq!(skills[0].plugin_name.as_deref(), Some("plugin-dev"));
|
||||
}
|
||||
|
||||
// ── Manifest `skills` entries pointing directly at skill dirs ──
|
||||
// Manifest `skills` entries pointing directly at skill dirs
|
||||
|
||||
fn make_registry_with_skill_dirs(
|
||||
name: &str,
|
||||
@@ -2006,11 +2009,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// discover_skills_for_paths and dedup_by_canonical_path tests removed --
|
||||
// these functions now live in kigi-tools::implementations::skills::discovery
|
||||
// and kigi-tools::types::skill_discovery_tracker, tested there.
|
||||
// discover_skills_for_paths and dedup_by_canonical_path live in
|
||||
// kigi-tools::implementations::skills::discovery and
|
||||
// kigi-tools::types::skill_discovery_tracker, and are tested there.
|
||||
|
||||
// ── Disabled skills marking ─────────────────────────────────────
|
||||
// Disabled skills marking
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_config_marks_skill_enabled_false() {
|
||||
@@ -2091,7 +2094,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bundled skills discovery ─────────────────────────────────────
|
||||
// Bundled skills discovery
|
||||
|
||||
#[tokio::test]
|
||||
async fn bundled_skills_are_discovered() {
|
||||
@@ -2180,7 +2183,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Command file discovery ────────────────────────────────────────
|
||||
// Command file discovery
|
||||
|
||||
/// Regression: project `.claude/commands` often sits under a full `.claude/**`
|
||||
/// gitignore with only `!.claude/skills/**` re-included (local-only vendor
|
||||
@@ -2312,7 +2315,7 @@ mod tests {
|
||||
assert!(deploy[0].path.contains("SKILL.md"));
|
||||
}
|
||||
|
||||
// ── Plugin skill identity ─────────────────────────────
|
||||
// Plugin skill identity
|
||||
|
||||
fn min_plugin(name: &str) -> crate::plugins::LoadedPlugin {
|
||||
use crate::plugins::discovery::PluginId;
|
||||
@@ -2430,7 +2433,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── collect_skill_config_dirs vendor gating ────────────
|
||||
// collect_skill_config_dirs vendor gating
|
||||
|
||||
#[test]
|
||||
fn collect_skill_config_dirs_gates_vendor_dirs() {
|
||||
@@ -2461,7 +2464,7 @@ mod tests {
|
||||
assert!(ends_with(&dirs, ".kigi"), "kigi must remain: {dirs:?}");
|
||||
}
|
||||
|
||||
// ── Same-scope frontmatter-name collisions (copied skill dirs) ──────
|
||||
// Same-scope frontmatter-name collisions (copied skill dirs)
|
||||
|
||||
fn named_skill(name: &str, path: &str, scope: SkillScope) -> SkillInfo {
|
||||
SkillInfo {
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
//! System prompts for built-in subagent profiles.
|
||||
//!
|
||||
//!
|
||||
//! ## Tool name resolution
|
||||
//!
|
||||
//! All tool names in these prompts use the `${{ tools.by_kind.* }}` template
|
||||
//! syntax from the `TemplateRenderer`. When the prompt is rendered via
|
||||
//! `PromptContext::render()` → `ToolBridge::render_prompt()`, MiniJinja
|
||||
//! resolves each variable to the current session's tool names.
|
||||
//!
|
||||
//! This means:
|
||||
//! - Tool names are NEVER hardcoded — they adapt to name overrides and
|
||||
//! alternate tool namespaces
|
||||
//! - If a tool kind is absent from the renderer's context, MiniJinja
|
||||
//! resolves it to an empty string (templates can also use
|
||||
//! `${%- if tools.by_kind.X %}` conditionals to hide entire sections)
|
||||
//!
|
||||
//! Tool-kind mapping (common names → ToolKind):
|
||||
//! Read → `${{ tools.by_kind.read }}`
|
||||
//! Write/Edit → `${{ tools.by_kind.edit }}`
|
||||
//! Glob → `${{ tools.by_kind.list }}`
|
||||
//! Grep → `${{ tools.by_kind.search }}`
|
||||
//! Bash → `${{ tools.by_kind.execute }}`
|
||||
//! WebSearch → `${{ tools.by_kind.web_search }}`
|
||||
//! Tool names inside these prompts are never hardcoded: they are
|
||||
//! `${{ tools.by_kind.* }}` template variables that MiniJinja resolves to the
|
||||
//! session's actual tool names during `ToolBridge::render_prompt()`, so they
|
||||
//! follow name overrides and alternate namespaces. A kind that is absent from
|
||||
//! the renderer context resolves to an empty string, which is why prompts guard
|
||||
//! whole sections with `${%- if tools.by_kind.X %}`.
|
||||
|
||||
pub use kigi_tool_types::{EXPLORE_PROMPT, GENERAL_PURPOSE_PROMPT, PLAN_PROMPT};
|
||||
|
||||
@@ -149,7 +149,7 @@ mod tests {
|
||||
.expect("codex template render failed")
|
||||
}
|
||||
|
||||
// ── Variable substitution ───────────────────────────────────────
|
||||
// Variable substitution
|
||||
|
||||
#[test]
|
||||
fn test_variable_substitution_tool_kind() {
|
||||
@@ -171,7 +171,7 @@ mod tests {
|
||||
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
|
||||
}
|
||||
|
||||
// ── Conditionals ────────────────────────────────────────────────
|
||||
// Conditionals
|
||||
|
||||
#[test]
|
||||
fn test_conditional_tool_present() {
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
assert_eq!(result, "Use {{ literal_braces }} in prose.");
|
||||
}
|
||||
|
||||
// ── Tool name overrides ─────────────────────────────────────────
|
||||
// Tool name overrides
|
||||
|
||||
#[test]
|
||||
fn test_tool_name_override() {
|
||||
@@ -225,7 +225,7 @@ mod tests {
|
||||
assert_eq!(result, "Use view_file and Edit.");
|
||||
}
|
||||
|
||||
// ── Base template rendering ─────────────────────────────────────
|
||||
// Base template rendering
|
||||
|
||||
#[test]
|
||||
fn test_base_template_renders() {
|
||||
@@ -355,7 +355,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Required sections regression ────────────────────────────────
|
||||
// Required sections regression
|
||||
|
||||
#[test]
|
||||
fn test_base_template_contains_required_sections() {
|
||||
@@ -380,7 +380,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mid-session mode switching ──────────────────────────────────
|
||||
// Mid-session mode switching
|
||||
|
||||
#[test]
|
||||
fn test_mid_session_switch_concise_to_full() {
|
||||
@@ -430,7 +430,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Determinism ─────────────────────────────────────────────────
|
||||
// Determinism
|
||||
|
||||
#[test]
|
||||
fn test_prompt_deterministic_across_renders() {
|
||||
@@ -451,7 +451,7 @@ mod tests {
|
||||
assert_eq!(a, b, "Full mode rendering must be deterministic");
|
||||
}
|
||||
|
||||
// ── Disabled tools ──────────────────────────────────────────────
|
||||
// Disabled tools
|
||||
|
||||
#[test]
|
||||
fn test_disabled_tools_omit_sections() {
|
||||
@@ -469,11 +469,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memory section ──────────────────────────────────────────────
|
||||
// Memory section
|
||||
|
||||
#[test]
|
||||
fn test_memory_enabled_does_not_render_memory_section() {
|
||||
// The <memory> section was removed from the minimal base prompt.
|
||||
// The <memory> section is absent from the minimal base prompt.
|
||||
// Even when the memory tools are registered AND memory_enabled=true,
|
||||
// the trimmed template must not render a memory section. (Complements
|
||||
// test_memory_disabled_omits_memory_section, which covers the default.)
|
||||
@@ -514,7 +514,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Web search disabled ─────────────────────────────────────────
|
||||
// Web search disabled
|
||||
|
||||
#[test]
|
||||
fn test_web_search_disabled_renders_without_crash() {
|
||||
@@ -534,7 +534,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Apply-patch template rendering ───────────────────────────────────
|
||||
// Apply-patch template rendering
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_renders() {
|
||||
@@ -634,9 +634,9 @@ mod tests {
|
||||
assert_eq!(a, b, "Subagent template rendering must be deterministic");
|
||||
}
|
||||
|
||||
// ── Task completion discipline ─────────────────────────────────
|
||||
// Task completion discipline
|
||||
//
|
||||
// The `<task_completion_discipline>` block was removed from both
|
||||
// The `<task_completion_discipline>` block is absent from both
|
||||
// base and subagent templates. These tests pin the deletion so the
|
||||
// block doesn't accidentally come back, and so the runtime TodoGate
|
||||
// doesn't start firing reminders that reference a non-existent
|
||||
@@ -681,7 +681,7 @@ mod tests {
|
||||
assert_template_size_under(&prompt, "subagent");
|
||||
}
|
||||
|
||||
// ── Guard invariant ─────────────────────────────────────────────
|
||||
// Guard invariant
|
||||
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
|
||||
// whose condition requires X (contains `tools.by_kind.X` at a word
|
||||
// boundary, with no top-level ` or `). If violated, X could render
|
||||
@@ -770,12 +770,12 @@ mod tests {
|
||||
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
|
||||
}
|
||||
|
||||
// ── Combination sweep ───────────────────────────────────────────
|
||||
// Combination sweep
|
||||
// Belt-and-braces: renders the base template across tool-kind subsets
|
||||
// and asserts no raw template tokens leak. The static guard test above
|
||||
// is the authoritative check; this one just catches syntax drift.
|
||||
|
||||
// ── is_non_interactive gating ──────────────────────────────────
|
||||
// is_non_interactive gating
|
||||
// Headless / SDK / stdio / generic-ACP sessions have no human typing
|
||||
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
|
||||
// `<user_guide>` TUI pointer are noise. Those sections must drop out
|
||||
@@ -783,7 +783,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn interactive_renders_shell_prefix_tip_and_user_guide() {
|
||||
// The `! <command>` shell-prefix tip was removed from the minimal
|
||||
// The `! <command>` shell-prefix tip is absent from the minimal
|
||||
// prompt. The <user_guide> block still renders for interactive
|
||||
// sessions only, so that's what we assert here.
|
||||
let mut p = default_placeholders();
|
||||
|
||||
@@ -334,7 +334,7 @@ mod tests {
|
||||
assert_eq!(original, loaded);
|
||||
}
|
||||
}
|
||||
/// A status under the cap passes through unchanged (trim is a no-op for
|
||||
/// A status under the cap passes through `unchanged` (trim is a no-op for
|
||||
/// real `git status --short --branch` output, which starts with `##`).
|
||||
#[test]
|
||||
fn normalize_git_status_passthrough_under_limit() {
|
||||
|
||||
@@ -48,7 +48,7 @@ mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
// ── resolve_workspace_user_dir (pure, no env vars) ───────────────
|
||||
// resolve_workspace_user_dir (pure, no env vars)
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_for_empty_root() {
|
||||
@@ -126,7 +126,7 @@ mod tests {
|
||||
assert_eq!(result, Some(user_dir));
|
||||
}
|
||||
|
||||
// ── workspace_user_relpath ───────────────────────────────────────
|
||||
// workspace_user_relpath
|
||||
|
||||
#[test]
|
||||
fn bare_username_is_nested_under_x() {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Shared git-repo dir-chain primitive.
|
||||
//!
|
||||
//! One `git2` discovery + one cwd→root walk, reused across the many repo-local
|
||||
//! config marker checks the folder-trust gate runs back-to-back. Lives in its
|
||||
//! own module (rather than `discovery`) because it is a generic repo-walk
|
||||
//! primitive consumed cross-crate by `kigi-workspace`, not agent-definition
|
||||
//! discovery.
|
||||
//! Lives in its own module rather than `discovery` because it is a generic
|
||||
//! repo-walk primitive consumed cross-crate by `kigi-workspace`, not
|
||||
//! agent-definition discovery.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -14,42 +12,28 @@ use std::path::{Path, PathBuf};
|
||||
///
|
||||
/// The folder-trust gate's `repo_configs_present` probes a dozen repo-local
|
||||
/// code-exec markers (`.mcp.json`, `.kigi/config.toml`, `.claude/settings.json`,
|
||||
/// project plugin/agent dirs, …) back-to-back on the agent startup path. Each
|
||||
/// marker walker used to run its own `discover` + cwd→root walk; sharing one
|
||||
/// `RepoDirChain` collapses that to a single traversal (each redundant syscall
|
||||
/// is taxed 10-100x on Windows, and on a non-git dir each `discover` walks to
|
||||
/// the filesystem root). Both the gate and the real loaders consume the same
|
||||
/// chain via `*_in` walker variants, so detection can't drift from loading.
|
||||
///
|
||||
/// The public cwd-taking delegators (`find_project_configs`,
|
||||
/// `project_plugin_dirs`, `project_agent_dirs`, …) now resolve through this
|
||||
/// chain too, so their non-gate callers (config watcher, reloader, the mcp/
|
||||
/// config loaders, inspect, upload, mcp_doctor) gain the per-level canonicalize
|
||||
/// below. That is deliberate: all those callers are cold (startup / file-change /
|
||||
/// session-setup / manual commands), never per-keystroke, and the canonical stop
|
||||
/// is strictly more correct.
|
||||
/// project plugin/agent dirs, …) back-to-back on the agent startup path, so a
|
||||
/// per-walker discovery + walk is a real cost: each redundant syscall is taxed
|
||||
/// 10-100x on Windows, and on a non-git dir each `discover` walks to the
|
||||
/// filesystem root. Both the gate and the real loaders consume the same chain
|
||||
/// via `*_in` walker variants, so detection can't drift from loading.
|
||||
///
|
||||
/// Outside a git repo `git_root` is `None` and `dirs` is just `[cwd]`, matching
|
||||
/// every walker's no-repo branch (probe `cwd` only).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoDirChain {
|
||||
/// Git worktree root (`workdir`), or `None` when `cwd` is not inside a repo.
|
||||
pub git_root: Option<PathBuf>,
|
||||
/// `cwd` up to and including `git_root`, cwd-first (`[cwd]` with no repo).
|
||||
pub dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl RepoDirChain {
|
||||
/// Resolve the chain for `cwd`: ONE `git2` discovery + ONE upward walk.
|
||||
pub fn resolve(cwd: &Path) -> Self {
|
||||
let git_root = git2::Repository::discover(cwd)
|
||||
.ok()
|
||||
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
|
||||
// Home-is-a-git-repo (dotfiles in $HOME): a discovery that walks up
|
||||
// to $HOME must NOT treat the whole home subtree as one repo, or
|
||||
// home-level `.kigi`/`.mcp.json`/plugins would look repo-local. Drop
|
||||
// it so cwd is handled as no-repo (probe cwd only). Home is compared
|
||||
// canonically to match the symlink handling in the walk below.
|
||||
// Dotfiles in $HOME make home itself a repo; treating that subtree
|
||||
// as repo-local would promote home-level `.kigi`/`.mcp.json`/plugins
|
||||
// to project config. Dropping the root makes cwd behave as no-repo.
|
||||
.filter(|root| !is_home_dir(root));
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
@@ -57,11 +41,10 @@ impl RepoDirChain {
|
||||
// Canonicalize only for the stop test so a symlinked cwd/ancestor
|
||||
// still halts AT the worktree root instead of over-walking to the
|
||||
// filesystem root; pushed dirs keep their original spelling (callers
|
||||
// `join` markers onto them, which resolve the same either way). The
|
||||
// per-level canonicalize is required to stop at root through a
|
||||
// symlinked ancestor while keeping raw spelling — do NOT reduce to a
|
||||
// 2-call `starts_with` variant (it would mis-handle a mid-chain
|
||||
// absolute symlink and reintroduce the over-walk).
|
||||
// `join` markers onto them, which resolve the same either way).
|
||||
// Canonicalizing per level is what makes that stop reliable — a
|
||||
// 2-call `starts_with` variant mis-handles a mid-chain absolute
|
||||
// symlink and over-walks.
|
||||
let root_canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.clone());
|
||||
let mut current = Some(cwd.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
@@ -81,9 +64,9 @@ impl RepoDirChain {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` canonicalizes to the user's home directory. Local (not reused
|
||||
/// from `kigi-workspace`, which depends on THIS crate) to keep the dep edge
|
||||
/// one-way; backs the home-is-dotfiles guard in [`RepoDirChain::resolve`].
|
||||
/// Whether `path` canonicalizes to the user's home directory. Duplicated here
|
||||
/// instead of reused from `kigi-workspace`, which depends on THIS crate, to keep
|
||||
/// the dep edge one-way.
|
||||
fn is_home_dir(path: &Path) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
@@ -94,8 +77,7 @@ fn is_home_dir(path: &Path) -> bool {
|
||||
|
||||
/// Existing `<dir>/<subdir>` directories under each dir of a precomputed
|
||||
/// cwd→git-root chain ([`RepoDirChain::dirs`]), in chain order (cwd-first, then
|
||||
/// each `subdirs` entry in order). Shared body for the project plugin/agent dir
|
||||
/// walkers so the byte-identical double-loop lives in one place.
|
||||
/// each `subdirs` entry in order).
|
||||
pub(crate) fn existing_subdirs_along(chain_dirs: &[PathBuf], subdirs: &[&str]) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for dir in chain_dirs {
|
||||
@@ -114,8 +96,8 @@ mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
/// RAII guard: set an env var, restore the prior value (or unset) on drop,
|
||||
/// so a test never leaves process-global env pointing at a dropped tempdir.
|
||||
/// Restores the prior value (or unsets) on drop, so a test never leaves
|
||||
/// process-global env pointing at a dropped tempdir.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
prev: Option<std::ffi::OsString>,
|
||||
@@ -140,8 +122,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_in_repo_yields_cwd_to_root_chain() {
|
||||
// A git-init'd tmp with a 2-deep subdir: the chain is cwd→root inclusive,
|
||||
// cwd-first, in the dirs' original spelling, and `git_root` is the root.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
let nested = tmp.path().join("a").join("b");
|
||||
@@ -156,8 +136,8 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
]
|
||||
);
|
||||
// `git_root` is the canonical worktree root (git2's `workdir`); compare by
|
||||
// canonical form so a `/tmp`→`/private/tmp` symlink doesn't fail the test.
|
||||
// git2's `workdir` is canonical, so compare canonically or a
|
||||
// `/tmp`→`/private/tmp` symlink fails the test.
|
||||
let root = chain.git_root.expect("inside a repo");
|
||||
assert_eq!(
|
||||
dunce::canonicalize(&root).unwrap(),
|
||||
@@ -167,10 +147,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_outside_repo_is_cwd_only() {
|
||||
// A non-git tmp: no discovery hit, so the chain is just `[cwd]` and there
|
||||
// is no git root. Only assert the no-repo shape when the temp dir is
|
||||
// genuinely outside any repo (a dev/CI checkout may place $TMPDIR inside
|
||||
// a larger git worktree).
|
||||
// Only assert the no-repo shape when the temp dir is genuinely outside
|
||||
// any repo: a dev/CI checkout may place $TMPDIR inside a larger git
|
||||
// worktree.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plain = tmp.path().join("plain");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
@@ -184,10 +163,8 @@ mod tests {
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn resolve_treats_home_git_repo_as_no_repo() {
|
||||
// Home-is-a-git-repo (dotfiles in $HOME): discovery walks up to $HOME,
|
||||
// but the guard drops that root so a subdir resolves as no-repo (probe
|
||||
// cwd only) instead of spanning the whole home subtree. $HOME is guarded
|
||||
// (dirs::home_dir reads it) and canonicalized to match the guard.
|
||||
// $HOME is process-global (`dirs::home_dir` reads it) so it needs the
|
||||
// guard, and canonicalized to match the comparison in `is_home_dir`.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = dunce::canonicalize(tmp.path()).unwrap();
|
||||
git2::Repository::init(&home).unwrap();
|
||||
@@ -203,8 +180,8 @@ mod tests {
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn resolve_keeps_non_home_git_root() {
|
||||
// The guard is home-EXACT: a git root that is NOT $HOME still resolves
|
||||
// normally (no over-trigger), so $HOME points at an unrelated dir here.
|
||||
// The guard is home-EXACT, so $HOME points at an unrelated dir here to
|
||||
// prove a non-home git root still resolves normally.
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = EnvVarGuard::set("HOME", home.path());
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
//! Reminder policy — wraps kigi-tools reminder config.
|
||||
|
||||
/// Default per-prompt fire cap for the runtime turn-end TodoGate. Used
|
||||
/// only as the default for `TodoGateConfig`; the runtime consumer reads
|
||||
/// the live value from `ReminderPolicy.todo_gate.max_fires_per_prompt`,
|
||||
/// so this constant is NOT a hardcoded cap.
|
||||
/// Seeds `TodoGateConfig::max_fires_per_prompt`; the gate reads the live value
|
||||
/// from `ReminderPolicy.todo_gate`, never this constant.
|
||||
pub const DEFAULT_TODO_GATE_MAX_FIRES: u32 = 2;
|
||||
|
||||
/// Session-level system reminder policy.
|
||||
///
|
||||
/// Controls whether system reminders are enabled and configures
|
||||
/// the TodoNudge and TodoGate behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReminderPolicy {
|
||||
/// Whether system reminders are enabled at all.
|
||||
pub enabled: bool,
|
||||
/// Configuration for the periodic TodoWrite nudge reminder.
|
||||
pub todo_nudge: TodoNudgeConfig,
|
||||
/// Configuration for the runtime turn-end TodoGate.
|
||||
pub todo_gate: TodoGateConfig,
|
||||
}
|
||||
|
||||
@@ -30,17 +22,13 @@ impl Default for ReminderPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the TodoWrite nudge reminder.
|
||||
///
|
||||
/// The system will remind the model to use `todo_write` when it
|
||||
/// hasn't done so within a configurable number of turns.
|
||||
/// Reminds the model to call `todo_write` once it has gone
|
||||
/// `turns_since_todo_write` turns without one, then stays quiet for
|
||||
/// `turns_between_reminders` turns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TodoNudgeConfig {
|
||||
/// Whether the TodoNudge reminder is enabled.
|
||||
pub enabled: bool,
|
||||
/// Number of turns since last `todo_write` call before nudging.
|
||||
pub turns_since_todo_write: u32,
|
||||
/// Minimum turns between nudge reminders.
|
||||
pub turns_between_reminders: u32,
|
||||
}
|
||||
|
||||
@@ -54,24 +42,19 @@ impl Default for TodoNudgeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the runtime turn-end TodoGate.
|
||||
///
|
||||
/// The gate inspects `TodoState` after every content-only assistant
|
||||
/// message and forces another turn via `<system-reminder>` injection
|
||||
/// if pending/unbacked-in-progress todos remain — see
|
||||
/// Turn-end gate: inspects `TodoState` after every content-only assistant
|
||||
/// message and forces another turn via `<system-reminder>` injection if
|
||||
/// pending/unbacked-in-progress todos remain — see
|
||||
/// `kigi-shell::session::acp_session::evaluate_todo_gate`.
|
||||
///
|
||||
/// **Disabled by default.** Operators opt in via the remote
|
||||
/// `todo_gate_enabled = true` remote settings key, or via the
|
||||
/// `--todo-gate` CLI flag (session-scoped force-enable, highest
|
||||
/// precedence).
|
||||
/// **Disabled by default.** Operators opt in via the `todo_gate_enabled`
|
||||
/// remote settings key, or via the `--todo-gate` CLI flag (session-scoped
|
||||
/// force-enable, highest precedence).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TodoGateConfig {
|
||||
/// Whether the gate runs at all.
|
||||
pub enabled: bool,
|
||||
/// Hard cap on how many times the gate may fire per user prompt
|
||||
/// before the next turn is allowed to end with `TurnOutcome::Completed`.
|
||||
/// Bounds the worst-case extra inference cost.
|
||||
/// Past this many fires per user prompt the next turn is allowed to end
|
||||
/// with `TurnOutcome::Completed`, bounding worst-case extra inference cost.
|
||||
pub max_fires_per_prompt: u32,
|
||||
}
|
||||
|
||||
@@ -108,16 +91,11 @@ mod tests {
|
||||
"TodoGate ships disabled; remote/local opt-in required"
|
||||
);
|
||||
assert_eq!(policy.todo_gate.max_fires_per_prompt, 2);
|
||||
// The two reminder mechanisms are independent — flipping one
|
||||
// must not change the other (regression guard).
|
||||
assert!(policy.todo_nudge.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_gate_enable_does_not_disturb_nudge() {
|
||||
// Remote opt-in (or `[reminder.todo_gate] enabled = true` local
|
||||
// config) flips the gate to on without touching the periodic
|
||||
// TodoNudge as a side-effect.
|
||||
let mut policy = ReminderPolicy::default();
|
||||
policy.todo_gate.enabled = true;
|
||||
assert!(policy.todo_gate.enabled);
|
||||
|
||||
Reference in New Issue
Block a user