diff --git a/docs/todo.md b/docs/todo.md index b15b83a..7af3c23 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -664,7 +664,7 @@ - **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。 - **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。 - [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。 -33. [ ] **devflow.md MVP(十四节多角色论证定稿)**:①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。 +33. [x] **devflow.md MVP(十四节多角色论证定稿)** ✅已实施(2026-10-12:devflow_md.rs 投影模块+第七段稳定尾追加双路径(主会话 build_runtime_prompt+子代理 spawn_agent 同源)/分区裁剪(纪律4000+领单2000+CLAUDE 4000/总6000)/conv_id 键会话级缓存零缓存击穿/缺失零回归;881 测试绿含 9 新):①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。 ### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复) - [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index e8cd3ad..58c77b5 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -969,7 +969,7 @@ pub(crate) async fn run_agentic_loop( // (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock, // 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。 let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt( - &session_arc, &conv_id, system_prompt, "", + &session_arc, &conv_id, system_prompt, "", h3_watch_dir.as_deref(), ).await; // ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ── @@ -3238,11 +3238,13 @@ fn completed_event( // 做 snapshot,消除原 L1157 第二次 lock 读。 // // 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。 +// 注:`work_dir` 供项目规则文件投影定位工作区(与被监视工作区同一目录),None=无授权目录。 async fn build_runtime_prompt( session_arc: &Arc>, conv_id: &str, base_prompt: String, _lang: &str, + work_dir: Option<&str>, ) -> (String, Vec) { // 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。 // conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。 @@ -3368,6 +3370,12 @@ async fn build_runtime_prompt( ); } + // 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、 + // 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量, + // 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。 + let system_prompt = + crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await; + (system_prompt, goals) } diff --git a/src-tauri/src/commands/ai/devflow_md.rs b/src-tauri/src/commands/ai/devflow_md.rs new file mode 100644 index 0000000..84871a1 --- /dev/null +++ b/src-tauri/src/commands/ai/devflow_md.rs @@ -0,0 +1,420 @@ +//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。 +//! +//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读—— +//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单); +//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。 +//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。 +//! +//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存—— +//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本, +//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。 +//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。 +//! +//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块 +//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。 +//! +//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。 +//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use crate::state::AllowedDirs; + +// ── 裁剪上限(字符数,中文一字一符) ── +// +// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、 +// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。 +/// 规范纪律分区上限 +const DISCIPLINE_MAX_CHARS: usize = 4000; +/// 领单分区上限 +const CLAIM_MAX_CHARS: usize = 2000; +/// CLAUDE.md 并入段上限 +const CLAUDE_MD_MAX_CHARS: usize = 4000; +/// 投影段总上限(各分区叠加后的二次硬闸) +const SECTION_MAX_CHARS: usize = 6000; + +/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空 +/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。 +const SECTION_CACHE_MAX: usize = 64; + +/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡) +const CLAIM_HEADING_KEY: &str = "领单"; +const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"]; + +static SECTION_CACHE: OnceLock>> = OnceLock::new(); + +fn section_cache() -> &'static Mutex> { + SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 工作目录解析:首个持久授权目录(排序取首稳定)。 +/// +/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致; +/// HashSet 迭代序不定,必须排序后取首才是稳定值。 +pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option { + let mut dirs: Vec = allowed.persistent.iter().cloned().collect(); + dirs.sort(); + dirs.first().map(|p| p.to_string_lossy().to_string()) +} + +/// 把投影段纯追加到 system prompt 尾部(两条组装路径的统一入口)。 +/// +/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续, +/// 调用方既有段内容不受任何影响(稳定尾段语义)。 +pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String { + match projection_section(work_dir, conv_id).await { + Some(seg) => format!("{}\n\n{}", prompt, seg), + None => prompt, + } +} + +/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。 +/// +/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。 +/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。 +async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option { + { + let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner()); + if let Some(hit) = cache.get(conv_id) { + return (!hit.is_empty()).then(|| hit.clone()); + } + } + let dir = work_dir.map(PathBuf::from); + // 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理 + let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir)) + .await + .unwrap_or_else(|_| (None, None)); + let (discipline, claiming) = devflow + .as_deref() + .map(extract_partitions) + .unwrap_or((String::new(), String::new())); + let section = build_section(&discipline, &claiming, claude.as_deref()); + let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner()); + if cache.len() >= SECTION_CACHE_MAX { + cache.clear(); + } + cache.insert(conv_id.to_string(), section.clone().unwrap_or_default()); + section +} + +/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。 +fn read_pair(dir: Option) -> (Option, Option) { + let Some(dir) = dir else { + return (None, None); + }; + (read_trimmed(&dir.join("devflow.md")), read_trimmed(&dir.join("CLAUDE.md"))) +} + +fn read_trimmed(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。 +/// 三路输入全空 → None(调用方零追加)。 +fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option { + let claude = claude.map(str::trim).filter(|c| !c.is_empty()); + if discipline.is_empty() && claiming.is_empty() && claude.is_none() { + return None; + } + let has_devflow = !discipline.is_empty() || !claiming.is_empty(); + let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)"); + // 优先级只在双文件并存时注明,单文件无冲突可注明 + if has_devflow && claude.is_some() { + sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准"); + } + sec.push(':'); + + if !discipline.is_empty() { + sec.push_str("\n\n### 规范纪律(devflow.md)\n"); + sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS)); + } + if !claiming.is_empty() { + sec.push_str("\n\n### 领单纪律(devflow.md)\n"); + sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS)); + } + if let Some(cla) = claude { + sec.push_str("\n\n### 项目规范(CLAUDE.md)\n"); + sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS)); + } + + // 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次 + if sec.chars().count() > SECTION_MAX_CHARS { + let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect(); + return Some(format!("{}\n…(投影段超出总上限已截断)", cut)); + } + Some(sec) +} + +/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。 +fn cap_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let cut: String = s.chars().take(max).collect(); + format!("{}\n…(超上限已截断)", cut) +} + +// ── devflow.md 分区解析 ── +// +// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的 +// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类 +// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按 +// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。 + +#[derive(Clone, Copy, PartialEq)] +enum Partition { + Discipline, + Claim, +} + +/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。 +fn heading_of(line: &str) -> Option<(usize, &str)> { + let t = line.trim_start(); + let level = t.len() - t.trim_start_matches('#').len(); + if level == 0 || level > 6 { + return None; + } + let text = t[level..].trim(); + if text.is_empty() { + return None; + } + Some((level, text)) +} + +fn classify_heading(text: &str) -> Option { + if text.contains(CLAIM_HEADING_KEY) { + return Some(Partition::Claim); + } + if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) { + return Some(Partition::Discipline); + } + None +} + +/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。 +fn extract_partitions(content: &str) -> (String, String) { + let mut discipline: Vec = Vec::new(); + let mut claiming: Vec = Vec::new(); + // 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题) + let mut cur: Option<(Partition, usize, String)> = None; + + for line in content.lines() { + let heading = heading_of(line); + // 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块 + let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv); + if boundary { + let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在"); + push_block(kind, body, &mut discipline, &mut claiming); + } else if let Some((_, _, body)) = cur.as_mut() { + // 更深层级标题属块内子结构,连行保留 + body.push_str(line); + body.push('\n'); + continue; + } + if let Some((lv, text)) = heading { + if let Some(kind) = classify_heading(text) { + cur = Some((kind, lv, String::new())); + } + } + } + if let Some((kind, _, body)) = cur.take() { + push_block(kind, body, &mut discipline, &mut claiming); + } + + let dev = discipline.join("\n").trim().to_string(); + let claim = claiming.join("\n").trim().to_string(); + if dev.is_empty() && claim.is_empty() { + return (content.trim().to_string(), String::new()); + } + (dev, claim) +} + +fn push_block(kind: Partition, body: String, discipline: &mut Vec, claiming: &mut Vec) { + let trimmed = body.trim(); + if trimmed.is_empty() { + return; + } + match kind { + Partition::Discipline => discipline.push(trimmed.to_string()), + Partition::Claim => claiming.push(trimmed.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// 测试临时目录计数器:进程内唯一,防并发测试互踩 + 防缓存键串扰 + static TMP_SEQ: AtomicUsize = AtomicUsize::new(0); + + fn unique_tag() -> String { + format!("{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst)) + } + + fn temp_project_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("devflow-md-test-{}", unique_tag())); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + fn write_file(dir: &Path, name: &str, content: &str) { + std::fs::write(dir.join(name), content).expect("写测试文件"); + } + + // ── 零回归:无两文件 → prompt 逐字不变 ── + #[tokio::test] + async fn missing_files_leave_prompt_unchanged() { + let base = "BASE PROMPT 段落".to_string(); + let dir = temp_project_dir(); + let out = append_projection(base.clone(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await; + assert_eq!(out, base, "两文件缺失时 prompt 必须逐字不变"); + let out_no_dir = append_projection(base.clone(), None, &format!("conv-{}", unique_tag())).await; + assert_eq!(out_no_dir, base, "无授权目录时 prompt 必须逐字不变"); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 只有 CLAUDE.md → 并入生效 ── + #[tokio::test] + async fn claude_only_merged() { + let dir = temp_project_dir(); + write_file(&dir, "CLAUDE.md", "# 项目约定\n- 提交信息用中文"); + let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await; + assert!(out.starts_with("BASE"), "既有段必须是前缀"); + assert!(out.contains("提交信息用中文"), "CLAUDE.md 正文须并入"); + assert!(out.contains("### 项目规范(CLAUDE.md)"), "并入段须带 CLAUDE.md 标注"); + assert!(!out.contains("devflow.md"), "仅 CLAUDE.md 时不得出现 devflow.md 标注"); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ── + #[tokio::test] + async fn both_files_devflow_priority() { + let dir = temp_project_dir(); + write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]"); + write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目"); + let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await; + let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入"); + let claim_pos = out.find("领单后改").expect("领单分区须注入"); + let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入"); + assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)"); + assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级"); + assert!(out.contains("### 规范纪律(devflow.md)")); + assert!(out.contains("### 领单纪律(devflow.md)")); + assert!(out.contains("### 项目规范(CLAUDE.md)")); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 超长裁剪:分区上限 + 总上限生效并注明 ── + #[test] + fn caps_truncate_with_marker() { + let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100); + let section = build_section(&long, "", None).expect("非空输入必有段"); + let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or(""); + assert!(discipline_part.contains("超上限已截断"), "分区超限须注明"); + let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap(); + assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限"); + + // 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸 + let discipline = "a".repeat(DISCIPLINE_MAX_CHARS); + let claiming = "b".repeat(CLAIM_MAX_CHARS); + let claude = "c".repeat(CLAUDE_MD_MAX_CHARS); + let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段"); + assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len()); + assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明"); + } + + // ── 稳定尾段:前段逐字保留,投影段独占尾部 ── + #[tokio::test] + async fn stable_tail_append() { + let dir = temp_project_dir(); + write_file(&dir, "CLAUDE.md", "- 规则条目"); + let base = "## 行为准则\n- 既有段内容\n"; + let out = append_projection(base.to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await; + assert!(out.starts_with(base), "既有段必须逐字保留为前缀"); + let tail = out.strip_prefix(base).unwrap(); + assert!(tail.starts_with("\n\n## 项目认知投影"), "投影段须紧跟既有段之后成稳定尾段"); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 会话缓存:同会话只读一次盘,文件中途改写不影响本会话;新会话读到新内容 ── + #[tokio::test] + async fn cached_per_conversation() { + let dir = temp_project_dir(); + let tag = unique_tag(); + write_file(&dir, "CLAUDE.md", "- 第一版规则"); + let conv = format!("conv-{}", tag); + let first = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await; + write_file(&dir, "CLAUDE.md", "- 第二版规则"); + let again = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await; + assert_eq!(first, again, "同会话第二次组装必须复用缓存(文件改写不影响)"); + assert!(first.contains("第一版规则")); + let fresh = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}-new", tag)).await; + assert!(fresh.contains("第二版规则"), "新会话须重读盘拿到最新内容"); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ── + #[test] + fn partition_extraction() { + let content = "\ +# 项目认知 + +前言段落应被裁掉。 + +## 规范纪律 +- 纪律条目一 + +### 提交规范 +- 子标题下的纪律条目 + +## 看板摘要 +- 未命中关键字的块应被裁掉 + +## 领单纪律 +- 领单条目一 +"; + let (dev, claim) = extract_partitions(content); + assert!(dev.contains("纪律条目一"), "规范纪律块须命中"); + assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留"); + assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉"); + assert_eq!(claim, "- 领单条目一", "领单分区须独立提取"); + + // 扁平文件(无命中标题)回退:整文件按规范纪律 + let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n"); + assert_eq!(dev, "- 扁平规则一\n- 扁平规则二"); + assert!(claim.is_empty()); + + // 深层标题起始的块:同级才截断 + let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n"); + assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断"); + assert!(!dev.contains("其他块内容")); + } + + // ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ── + #[test] + fn work_dir_sorted_first() { + let mut allowed = AllowedDirs::default(); + allowed.persistent.insert(PathBuf::from("E:/wk-b/b-project")); + allowed.persistent.insert(PathBuf::from("E:/wk-a/a-project")); + assert_eq!(work_dir_of(&allowed).as_deref(), Some("E:/wk-a/a-project")); + let empty = AllowedDirs::default(); + assert_eq!(work_dir_of(&empty), None, "无授权目录返 None"); + } + + // ── 构建纯函数:全空 None / 单区缺省不产生空小节 ── + #[test] + fn build_section_edge_cases() { + assert!(build_section("", "", None).is_none(), "三路全空须 None"); + assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失"); + let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段"); + assert!(only_claim.contains("### 领单纪律(devflow.md)")); + assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节"); + assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级"); + } +} diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index ba57d4e..7aaeae6 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -36,6 +36,8 @@ pub mod command_stream; pub mod commands; pub mod compress; pub mod conversation; +// 项目规则文件投影(devflow.md/CLAUDE.md):会话启动读取并入 system prompt 稳定尾段 +pub(crate) mod devflow_md; pub mod event_bus; pub mod http; pub mod fetch_url; diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index d97c1db..e0801d6 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -1410,6 +1410,7 @@ fn make_test_spawn_agent_ctx(db: &Arc) -> crate::commands::ai::tools:: crate::commands::ai::agentic::conv_state::ConvStateStore::new(), ), llm_concurrency: LlmConcurrency::new(3, 2), + allowed_dirs: Arc::new(tokio::sync::RwLock::new(crate::state::AllowedDirs::default())), db: db.clone(), sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(), } diff --git a/src-tauri/src/commands/ai/tools/spawn_agent.rs b/src-tauri/src/commands/ai/tools/spawn_agent.rs index 8d81995..3a21d7b 100644 --- a/src-tauri/src/commands/ai/tools/spawn_agent.rs +++ b/src-tauri/src/commands/ai/tools/spawn_agent.rs @@ -17,7 +17,7 @@ use std::sync::Arc; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use df_ai::ai_tools::{ AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta, @@ -52,6 +52,9 @@ pub struct SpawnAgentCtx { pub conv_states: Arc, /// LLM 并发控制(子循环 global + per_conv(child) 双份额) pub llm_concurrency: LlmConcurrency, + /// 文件访问授权白名单(子代理投影段定位工作区:首个持久授权目录; + /// 与 AppState.allowed_dirs 共享同一 Arc) + pub allowed_dirs: Arc>, /// 数据库句柄(子循环 provider 解析) pub db: Arc, /// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc) @@ -268,6 +271,15 @@ async fn spawn_agent_handler( let task_prompt = build_task_prompt(&task, &allowed, &trimmed); // P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划) let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang); + // 项目规则文件投影与主会话同源:父会话工作区的 devflow.md/CLAUDE.md 对子代理同样生效 + // (以父会话为缓存键,复用父会话已读内容零重复读盘;无授权目录/无文件时原样返回)。 + let work_dir = { + let snap = ctx.allowed_dirs.read().await; + crate::commands::ai::devflow_md::work_dir_of(&snap) + }; + let system_prompt = crate::commands::ai::devflow_md::append_projection( + system_prompt, work_dir.as_deref(), &parent_conv_id, + ).await; // P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台 // 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9f07604..d402ab7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -362,6 +362,7 @@ impl AppState { session: ai_session.clone(), conv_states: conv_states.clone(), llm_concurrency: llm_concurrency.clone(), + allowed_dirs: allowed_dirs.clone(), db: db.clone(), sub_agents: sub_agent_registry.clone(), };