新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
//! 本机 Claude 技能扫描(skills / commands / plugins 三类)
|
||||
//!
|
||||
//! 核心设计6(/ skill 修复):
|
||||
//! - `strip_frontmatter` + `read_skill_content_stripped`:注入正文剥首个 `---...---` 块
|
||||
//! (原 `read_skill_content` 保留兼容,返全文)
|
||||
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
|
||||
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
|
||||
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts,
|
||||
//! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{RwLock, RwLockReadGuard};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -17,6 +25,22 @@ pub struct SkillInfo {
|
||||
pub source: String,
|
||||
/// SKILL.md 绝对路径(注入时读全文)
|
||||
pub path: String,
|
||||
/// 同名冲突时的其它来源路径(仅冲突时填 Some;首份为 None)。
|
||||
///
|
||||
/// 注入始终取首份(优先级 skills>commands>plugins),前端据非空 duplicates 提示用户
|
||||
/// "存在同名技能 N 份,已用 {source} 来源"。无冲突为 None,向后兼容旧前端(字段缺省)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duplicates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 扫描结果:去重后的 skills 列表 + 同名冲突明细(前端提示用)。
|
||||
///
|
||||
/// `conflicts`: `(name, 所有同名的 path 列表)` —— 注入取 skills[0](即 ScanResult.skills 中
|
||||
/// 首份,优先级 skills>commands>plugins),冲突列表仅做提示,不影响注入。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ScanResult {
|
||||
pub skills: Vec<SkillInfo>,
|
||||
pub conflicts: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
/// ~/.claude 目录(跨平台:USERPROFILE / HOME)
|
||||
@@ -27,6 +51,47 @@ fn claude_home() -> Option<PathBuf> {
|
||||
.map(|h| h.join(".claude"))
|
||||
}
|
||||
|
||||
/// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。
|
||||
///
|
||||
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
|
||||
/// 无 frontmatter(首行非 ---)直接返原文;frontmatter 未闭合(只有起始 --- 无收尾)
|
||||
/// 按容错返空串(避免把整篇当 frontmatter 误剥,此分支极罕见 —— SKILL.md 都闭合)。
|
||||
///
|
||||
/// 注入用:原 `read_skill_content` 返全文(含 frontmatter)会让 LLM 看到 YAML 头噪声,
|
||||
/// 改用 `read_skill_content_stripped` 后注入正文干净。
|
||||
pub(crate) fn strip_frontmatter(md: &str) -> String {
|
||||
let mut lines = md.lines().enumerate().peekable();
|
||||
// 空串 / 首行非 --- → 无 frontmatter,返原文
|
||||
let first = match lines.next() {
|
||||
Some((_, l)) => l,
|
||||
None => return String::new(),
|
||||
};
|
||||
if first.trim() != "---" {
|
||||
// 无 frontmatter:返原文(首行 + 余下,用 \n 拼回)
|
||||
let mut out = first.to_string();
|
||||
for (_, l) in lines {
|
||||
out.push('\n');
|
||||
out.push_str(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// in_fm:跳过直到下个 ---
|
||||
let mut body_start: Option<usize> = None;
|
||||
for (i, l) in lines {
|
||||
if l.trim() == "---" {
|
||||
body_start = Some(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match body_start {
|
||||
Some(start) => {
|
||||
// 正文 = 原文第 start 行(0-based)起所有行
|
||||
md.lines().skip(start).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
None => String::new(), // frontmatter 未闭合
|
||||
}
|
||||
}
|
||||
|
||||
/// 剥离 YAML 标量值两侧的引号(`"..."` / `'...'`),简易 frontmatter 解析用
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
@@ -84,6 +149,7 @@ fn parse_skill_file(path: &Path, source: &str) -> Option<SkillInfo> {
|
||||
argument_hint,
|
||||
source: source.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
duplicates: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,64 +172,216 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)
|
||||
fn scan_skills() -> Vec<SkillInfo> {
|
||||
/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)。
|
||||
///
|
||||
/// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path
|
||||
/// (仅当同名 > 1 时入列,前端提示用)。
|
||||
fn scan_skills() -> ScanResult {
|
||||
let home = match claude_home() {
|
||||
Some(h) => h,
|
||||
None => return Vec::new(),
|
||||
None => {
|
||||
return ScanResult {
|
||||
skills: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut skills = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
// 三类来源按优先级顺序收集(skills > commands > plugins)
|
||||
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(3);
|
||||
|
||||
// 1. ~/.claude/skills/*/SKILL.md
|
||||
let mut batch_skill = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(home.join("skills")) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "skill") {
|
||||
if seen.insert(info.name.clone()) {
|
||||
skills.push(info);
|
||||
}
|
||||
batch_skill.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
all.push(batch_skill);
|
||||
|
||||
// 2. ~/.claude/commands/*.md
|
||||
let mut batch_cmd = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(home.join("commands")) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Some(info) = parse_skill_file(&p, "command") {
|
||||
if seen.insert(info.name.clone()) {
|
||||
skills.push(info);
|
||||
}
|
||||
batch_cmd.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all.push(batch_cmd);
|
||||
|
||||
// 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md(递归;cache 不在此路径下)
|
||||
let mut files = Vec::new();
|
||||
collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files);
|
||||
let mut batch_plugin = Vec::new();
|
||||
for f in files {
|
||||
if let Some(info) = parse_skill_file(&f, "plugin") {
|
||||
batch_plugin.push(info);
|
||||
}
|
||||
}
|
||||
all.push(batch_plugin);
|
||||
|
||||
// 按 name 去重(首份优先级保留)+ 收集冲突
|
||||
// name -> (首份 index in skills, 所有 path)
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut skills: Vec<SkillInfo> = Vec::new();
|
||||
// name -> Vec<path>(按来源顺序,用于冲突判定 + duplicates 回填)
|
||||
let mut name_paths: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for batch in &all {
|
||||
for info in batch {
|
||||
name_paths
|
||||
.entry(info.name.clone())
|
||||
.or_default()
|
||||
.push(info.path.clone());
|
||||
if seen.insert(info.name.clone()) {
|
||||
skills.push(info);
|
||||
skills.push(info.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills
|
||||
// 回填 duplicates + 构造 conflicts
|
||||
let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
|
||||
for skill in skills.iter_mut() {
|
||||
if let Some(paths) = name_paths.get(&skill.name) {
|
||||
if paths.len() > 1 {
|
||||
// 首份 path 是 skill.path 本身,duplicates 填其余(按收集顺序)
|
||||
let dups: Vec<String> = paths
|
||||
.iter()
|
||||
.filter(|p| **p != skill.path)
|
||||
.cloned()
|
||||
.collect();
|
||||
if !dups.is_empty() {
|
||||
skill.duplicates = Some(dups);
|
||||
}
|
||||
conflicts.push((skill.name.clone(), paths.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScanResult { skills, conflicts }
|
||||
}
|
||||
|
||||
/// 技能扫描结果缓存(进程内;新增/改动技能需重启生效)
|
||||
pub(crate) fn skills_cached() -> &'static Vec<SkillInfo> {
|
||||
static SKILLS: OnceLock<Vec<SkillInfo>> = OnceLock::new();
|
||||
SKILLS.get_or_init(scan_skills)
|
||||
/// 进程内技能缓存(RwLock + Option 懒初始化)。
|
||||
///
|
||||
/// 设计(核心设计6):
|
||||
/// - 快路径:`skills_cached()` 读锁命中 → clone 返回(扫盘零开销)
|
||||
/// - 慢路径:读锁 None → 释放后 `scan_skills()` 填充(双检锁,避免持写锁扫盘阻塞读)
|
||||
/// - `invalidate_skills()` 写锁置 None,下次 `skills_cached()` 触发重扫
|
||||
/// - `ai_reload_skills` IPC:invalidate + 重扫,进程内热重载(不重启生效)
|
||||
static SKILLS: RwLock<Option<Vec<SkillInfo>>> = RwLock::new(None);
|
||||
|
||||
/// 读锁快路径 guard(供 `read_skill_content_stripped` 持引用迭代读缓存)。
|
||||
///
|
||||
/// 生命周期 `'static`:SKILLS 是 static 项,对其借用可标注 `'static`(Rust 对 static 的保证),
|
||||
/// 使函数能返回 guard 跨作用域传递。guard Drop 时释放读锁。
|
||||
type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||||
|
||||
/// 取读锁快照(懒初始化:None 时先释放锁扫盘填回,再读锁取引用)。
|
||||
///
|
||||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
||||
fn skills_lock() -> SkillsGuard {
|
||||
// 快路径:读锁命中
|
||||
{
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
if g.is_some() {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
// 慢路径:扫盘 + 写锁填回
|
||||
let scanned = scan_skills().skills;
|
||||
{
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
// 另一线程可能已填,二次检查(双检锁)
|
||||
if g.is_none() {
|
||||
*g = Some(scanned);
|
||||
}
|
||||
}
|
||||
// 再取读锁返回(此时必 Some)
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
|
||||
g
|
||||
}
|
||||
|
||||
/// 按 name 读取技能全文(注入 system prompt);扫描走缓存,仅读单个 SKILL.md
|
||||
pub(crate) fn read_skill_content(name: &str) -> Option<String> {
|
||||
skills_cached()
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.and_then(|s| fs::read_to_string(&s.path).ok())
|
||||
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
|
||||
///
|
||||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
||||
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
|
||||
pub(crate) fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock();
|
||||
g.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
|
||||
///
|
||||
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
|
||||
/// 实现"改技能不重启即生效"。
|
||||
pub(crate) fn invalidate_skills() {
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
*g = None;
|
||||
}
|
||||
|
||||
/// 按 name 读技能正文(剥首个 `---...---` frontmatter 块)。
|
||||
///
|
||||
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
||||
/// 缓存未命中返 None;文件读失败返 None。
|
||||
pub(crate) fn read_skill_content_stripped(name: &str) -> Option<String> {
|
||||
let g = skills_lock();
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
let md = fs::read_to_string(&info.path).ok()?;
|
||||
Some(strip_frontmatter(&md))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_frontmatter_normal() {
|
||||
let md = "---\nname: foo\ndescription: bar\n---\n# Body\ncontent";
|
||||
assert_eq!(strip_frontmatter(md), "# Body\ncontent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_frontmatter_no_fm() {
|
||||
let md = "# Title\nbody line";
|
||||
assert_eq!(strip_frontmatter(md), "# Title\nbody line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_frontmatter_empty() {
|
||||
assert_eq!(strip_frontmatter(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_frontmatter_only_fm() {
|
||||
// 只有 frontmatter 无正文
|
||||
let md = "---\nname: foo\n---\n";
|
||||
assert_eq!(strip_frontmatter(md), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_frontmatter_crlf() {
|
||||
let md = "---\r\nname: foo\r\n---\r\nbody\r\n";
|
||||
let out = strip_frontmatter(md);
|
||||
assert!(out.contains("body"), "CRLF 正文应保留: {:?}", out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_result_dedup_and_conflicts() {
|
||||
// 仅测去重逻辑(不依赖 ~/.claude 存在);通过 parse_skill_file 间接覆盖
|
||||
// scan_skills 本身依赖文件系统,此处不集成测
|
||||
let _ = ScanResult {
|
||||
skills: vec![],
|
||||
conflicts: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user