diff --git a/.blueprint/modules/blueprint-buff.md b/.blueprint/modules/blueprint-buff.md index b17f936..b6792ff 100644 --- a/.blueprint/modules/blueprint-buff.md +++ b/.blueprint/modules/blueprint-buff.md @@ -69,3 +69,13 @@ id: blueprint-buff - 实现(2026-10-02,分支 feat/blueprint-buff):infer_task_marks 收口三道闸——last_commit 非 head 祖先跳过(git 出错记日志)、本批 .blueprint/modules patch 新增 🔵/✅/🔴 标题行(非原样搬移)跳过、余下才走 files 匹配;同一批次全程用轮询读到的 HEAD sha。匹配:parse_task_files(, 、 , 分隔,丢空项)+ file_matches(精确或 / 边界后缀,去反向后缀)。蓝图同步提示词改为「核实仅共用文件的 🔵 可降回 📋 并写明原因」。边界分析补 🔴、head 口径、git 异常日志三处。 - verification: "2026-10-02:cargo test 256 passed / 0 failed;buff 9 条新测试(精确命中、非 📋 不动、反向后缀不命中、/ 边界、尾逗号空项、「、」分隔与 A4 实例、🔵/✅/🔴 推进判定、搬移不算推进、临时 git 仓库端到端:同批显式推进不推断 / 无推进兜底标 🔵 / checkout 非祖先跳过 / 首次接入兜底)。回放 master 240 个 first-parent 提交:命中 50 → 5,79 个批次因显式推进整批抑制,53af8c3/239d9fd/79853b7 对 P6/P7/P8/A4 零命中。" - known_limits: "仍是启发式:不更新蓝图的 PR(如 Codex PR)碰共享文件仍可能误标;一次 pull 多个合并时,一个 PR 的显式推进会屏蔽另一个 PR 的兜底;只看已提交的蓝图改动,主仓库里只提交代码、🔵 锁留在工作区时仍会推断;files 项带反引号(4/311)或括号注释(21/311)永不命中(原有行为,未改);WSL 路径项目上新增 git 辅助不分支,失败即不推断。" + +### ✅ B2 — 跟进维护既有蓝图内容也算归属已显式 +- status: done +- complexity: S +- files: src-tauri/src/commands/buff.rs +- acceptance: "agent_verify: [cargo test buff] ⑧ 批内只改写已推进卡的既有行(无标题变化)+ 共用文件改动 → 0 张被标;⑨ 批内纯追加新 📋 卡 + 共用文件改动 → 兜底仍标 🔵(P8 与新卡);edits_existing_lines:hunk 内 `-` 行算、文件头 `---`/`--- /dev/null` 不算。[变异] 去掉 B2 条件后 ⑧ 失败(left 1, right 0)。human_verify: 主仓库快进到含本卡的提交后,下一次只补记已完成卡的 PR 合并,工作区无卡片被标 🔵" +- 起因:2026-10-03 05:49 主仓库快进到 19b9388(PR #31:只补记 ✅ P7 的 known_limits,无标题变化),35 秒后 P6/P8(onboarding.rs)与 A4(mcp_server.rs)再次被误标 🔵——B1 把「跟进已完成卡」判为无推进。B1 known_limits 预言的残余噪声第一次出现。 +- 实现:infer_task_marks 增加第二个显式归属条件 edits_existing_lines——本批 .blueprint/modules patch 在 hunk 内有 `-` 行(删改既有内容)即视为 agent 在跟进维护,不推断;纯追加(新立 📋 卡、新模块)仍走兜底,保留「立卡即实现、忘了加锁」的捕获。 +- verification: "2026-10-03:cargo test 284 passed / 0 failed / 5 ignored;buff 10 passed(新增 edits_existing_lines 单测与端到端 ⑧⑨);变异验证见 acceptance。回放 master 260 个 first-parent 批次:B1 下无推进 172 批,B2 新增抑制 27 批,其中含代码改动的 6 批:19b9388(本次误标)、8f3be6b、0d4c2ad、cbc1d46、23de433、0552180,其余为只改蓝图的 docs/chore 提交(本就不会命中代码卡);纯追加立卡的 9881b56、9c2064e、4f5cf43 保持兜底。" +- known_limits: "同批若只改写了无关 📋 卡的文字同时改代码且忘了加锁,B2 会少标(漏信号,不误标)。下次再出现误标时按 B1 决策记录升级到方案 C(files 重叠改记 DB「可能相关」信号,不改卡片,L 级)。" diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index e4d71b1..496f3e8 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -369,7 +369,8 @@ fn get_new_commits( /// /// 为什么是兜底:agent 按两阶段锁开工前已把在做的卡标 🔵,提交落地时仍是 📋 的卡多半没在做, /// files 重叠(尤其共享热点文件)命中的恰是它们——2026-10-02 回放 master 历史 50 次命中仅 3 次明确正确。 -/// 本批已有卡被推进说明归属已显式,不再猜;整批无推进才视为「agent 忘了加锁」。 +/// 本批已有卡被推进、或已有蓝图内容被删改(跟进维护,B2)说明归属已显式,不再猜; +/// 整批既无推进、也只是纯追加(或未碰蓝图)才视为「agent 忘了加锁」。 fn infer_task_marks(root: &Path, last_commit: Option<&str>, head: &str) -> Result { if let Some(prev) = last_commit { if !is_ancestor(root, prev, head) { @@ -381,11 +382,31 @@ fn infer_task_marks(root: &Path, last_commit: Option<&str>, head: &str) -> Resul return Ok(0); } match get_blueprint_patch(root, last_commit, head) { - Some(patch) if !has_explicit_advance(&patch) => update_task_statuses(root, &changed), + Some(patch) if !has_explicit_advance(&patch) && !edits_existing_lines(&patch) => { + update_task_statuses(root, &changed) + } _ => Ok(0), } } +/// 蓝图 patch 是否删改了已有行(hunk 内出现 `-` 行),即 agent 本批在跟进维护既有蓝图内容, +/// 例如给已 ✅ 的卡补记审查修复。纯追加(新立 📋 卡、新模块)不算——那正是「立卡即实现、忘了加锁」的兜底场景。 +/// 为什么(B2,2026-10-03):PR #31 只补记 ✅ P7 正文、无标题变化,B1 判为无推进, +/// 共用 onboarding.rs / mcp_server.rs 使 P6/P8/A4 再次被误标 🔵。 +fn edits_existing_lines(patch: &str) -> bool { + let mut in_hunk = false; + for line in patch.lines() { + if line.starts_with("diff --git ") { + in_hunk = false; + } else if line.starts_with("@@") { + in_hunk = true; + } else if in_hunk && line.starts_with('-') { + return true; + } + } + false +} + /// 蓝图 patch 中是否有任务卡被显式推进:新增了 `### 🔵` / `### ✅` / `### 🔴` 标题行, /// 且不是同一行的原样搬移(删除侧有完全相同的行视为重排,不算推进)。 /// 🔴 也算:agent 把卡标为受阻,说明它正在做那张卡,归属同样已显式。 @@ -658,6 +679,26 @@ mod tests { assert!(!has_explicit_advance("")); } + #[test] + fn edits_existing_lines_only_counts_removed_lines_inside_hunks() { + // 跟进已完成卡:改写既有行(19b9388 补记 ✅ P7 正文) + let followup = "diff --git a/.blueprint/modules/m.md b/.blueprint/modules/m.md\n\ + --- a/.blueprint/modules/m.md\n+++ b/.blueprint/modules/m.md\n\ + @@ -3 +3 @@\n- known_limits: 旧\n+ known_limits: 旧;已补齐\n"; + assert!(edits_existing_lines(followup)); + // 纯追加(新立 📋 卡):只有文件头的 `---`,hunk 内无 `-` 行 + let append = "diff --git a/.blueprint/modules/m.md b/.blueprint/modules/m.md\n\ + --- a/.blueprint/modules/m.md\n+++ b/.blueprint/modules/m.md\n\ + @@ -9,0 +10,2 @@\n+### 📋 P9 — 新卡\n+- status: todo\n"; + assert!(!edits_existing_lines(append)); + // 新增模块文件:`--- /dev/null` 在 hunk 外 + let new_file = "diff --git a/.blueprint/modules/n.md b/.blueprint/modules/n.md\n\ + new file mode 100644\n--- /dev/null\n+++ b/.blueprint/modules/n.md\n\ + @@ -0,0 +1 @@\n+id: n\n"; + assert!(!edits_existing_lines(new_file)); + assert!(!edits_existing_lines("")); + } + // ── 端到端:临时 git 仓库 ── fn git(dir: &Path, args: &[&str]) -> String { @@ -721,6 +762,26 @@ mod tests { assert!(read_md().contains("### 🔵 P8")); git(&dir, &["checkout", "--", "."]); + // ⑧ 跟进已推进的 P1(改写其既有行、无标题变化)+ 共用文件改动 → 归属已显式,不推断(B2) + std::fs::write(dir.join("src/a.rs"), "4").unwrap(); + std::fs::write( + &md_path, + md("🔵").replacen("- acceptance: x\n", "- acceptance: x;审查修复\n", 1), + ) + .unwrap(); + let c5 = commit_all(&dir, "fix: P1 审查修复"); + assert_eq!(infer_task_marks(&dir, Some(&c3), &c5).unwrap(), 0); + assert!(read_md().contains("### 📋 P8")); + + // ⑨ 纯追加新 📋 卡 + 共用文件改动(立卡即实现、忘了加锁)→ 兜底仍生效 + std::fs::write(dir.join("src/a.rs"), "5").unwrap(); + let appended = read_md() + &card("📋", "P9 — 新卡", "src/a.rs"); + std::fs::write(&md_path, appended).unwrap(); + let c6 = commit_all(&dir, "feat: P9"); + assert_eq!(infer_task_marks(&dir, Some(&c5), &c6).unwrap(), 2); + assert!(read_md().contains("### 🔵 P8") && read_md().contains("### 🔵 P9")); + git(&dir, &["checkout", "--", "."]); + // ⑦ checkout 到不含 last_commit 的分支:HEAD 非前进,不推断 git(&dir, &["checkout", "-q", "-b", "side", &c1]); std::fs::write(dir.join("src/a.rs"), "side").unwrap();