//! 一键接入报告(agent-client-parity P8d):分步状态、「完成」判定、L1 矩阵、项目文件改动、HEAD 断言、 //! 建议提交信息与 next_actions。 //! //! 「完成(done)」只按三端可用子集判定(P8 卡):所有步骤已收敛或按边界不做,且身份、托管块版本、两份 skill、 //! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪, //! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然 //! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。 //! //! 统筹决定(2026-10-03,P8d 审查 ⑩):L1 的 Codex 列里仅因 codex_project_untrusted 未就绪的格只等用户在 Codex 里 //! 信任该目录(炼境不写 Codex 信任表,设计 Q5),不计入「L1 适用格」,在 completion 单列为 pending_user「Codex 信任」、 //! next_actions 保留 codex_trust;其余原因的 Codex 格照常计入。非 git 目录仍到不了 done;仓库子目录与 worktree //! (.git 是文件)的「lefthook hook 挂载」检查只认项目根 .git/hooks,同样到不了 done,由 next_action repo_layout 说明原因。 use super::clients::{ClientId, ClientOutcome}; use super::plan::{BlockedItem, NextAction, OnboardingPlan}; use super::probe_l1::{Cause, Cell, L1Report}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::time::Instant; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OnboardStatus { /// 三端可用子集全部就绪 Done, /// 已执行写入,但有步骤未完成或可用子集未全部就绪(不自动回滚) Partial, /// 预检未通过(未登记、空路径、相对路径、根目录不存在),零写入 Failed, /// 未确认、digest 不符或计划有阻断项,零写入 Refused, /// 同一项目已有一键接入在执行,零写入 Busy, } /// 单步状态(设计 §4 第 2 步)。converged / applied / skipped / not_applicable 不阻断依赖它的步骤。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum StepStatus { /// 已一致,未改动 Converged, /// 本次写入或刷新 Applied, /// 按边界不改动(如保留手写配置) Skipped { reason: String, }, /// 本机或本项目不适用(未安装、控制面、WSL、非 git 仓库) NotApplicable { reason: String, }, /// 依赖的步骤未完成,本步未执行 Blocked { dep: String, }, Failed { reason: String, }, NeedsHuman { reason: String, }, } impl StepStatus { /// 已处于期望状态或按边界不做:依赖它的步骤照常执行。 pub fn is_settled(&self) -> bool { matches!( self, Self::Converged | Self::Applied | Self::Skipped { .. } | Self::NotApplicable { .. } ) } /// 客户端适配器 apply 的结果 → 步骤状态。 pub fn from_client(outcome: &ClientOutcome) -> Self { match outcome { ClientOutcome::Converged => Self::Converged, ClientOutcome::Applied | ClientOutcome::Refreshed | ClientOutcome::Removed => { Self::Applied } ClientOutcome::ManualKept => Self::Skipped { reason: "manual_kept".into(), }, ClientOutcome::Deferred => Self::Skipped { reason: "deferred".into(), }, ClientOutcome::NotInstalled => Self::NotApplicable { reason: "not_installed".into(), }, ClientOutcome::NotApplicable { reason } => Self::NotApplicable { reason: serde_json::to_value(reason) .ok() .and_then(|value| value.as_str().map(str::to_string)) .unwrap_or_default(), }, ClientOutcome::OtherProject { .. } | ClientOutcome::NeedsHuman { .. } | ClientOutcome::Unavailable { .. } => Self::NeedsHuman { reason: outcome.describe(), }, // apply 不应返回 missing(那是只读判定的状态);出现即按未写成处理 ClientOutcome::Missing => Self::Failed { reason: outcome.describe(), }, ClientOutcome::Failed { reason } => Self::Failed { reason: reason.clone(), }, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StepReport { /// identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh /// (本机步骤与计划 machine_changes 同名,便于「预计 → 实际」对照) pub step: String, #[serde(flatten)] pub status: StepStatus, pub detail: String, } impl StepReport { pub fn new(step: &str, status: StepStatus, detail: impl Into) -> Self { Self { step: step.to_string(), status, detail: detail.into(), } } } /// 「完成」判定的一项(来源:l0 静态检查、clients 适配器只读判定、l1、repo)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CompletionItem { pub item: String, pub source: String, pub ready: bool, pub detail: String, /// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false, /// 但不计入 done 判定;对应的 next_action 保留 #[serde(default)] pub pending_user: bool, } impl CompletionItem { fn new(item: impl Into, source: &str, ready: bool, detail: impl Into) -> Self { Self { item: item.into(), source: source.into(), ready, detail: detail.into(), pending_user: false, } } } /// 项目根在 git 里的位置:决定改动能否报告、「lefthook hook 挂载」能否就绪(该检查只认项目根 .git/hooks)。 #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RepoLayout { /// 不在 git 工作区内:repo_changes 为 unavailable,到不了 done NotGit, /// 项目根就是仓库根(.git 为目录) RepoRoot, /// 项目根是外层仓库的子目录(repo_root 为外层仓库根,取不到时为空) Subdirectory { repo_root: String }, /// 项目根的 .git 是文件:linked worktree 或子模块(hooks_dir 为 hook 实际所在目录,取不到时为空) GitFile { hooks_dir: String }, } /// 项目文件的待提交改动:炼境只报告、不 add、不 commit。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state", rename_all = "snake_case")] pub enum RepoChanges { Available { /// `git status --porcelain --untracked-files=all -- .` 的逐行输出:只含项目根目录内的改动 /// (项目根是外层仓库的子目录时不列外层仓库的其他改动),路径按 git 惯例相对仓库根 status_porcelain: Vec, /// `git diff --stat -- .`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行) diff_stat: String, /// 待提交改动里属于接入的文件(相对项目根):本次写入的,加上接入写入序列管理、仍未跟踪的 /// (多为之前的一键接入新建、尚未提交)。提交时只 add 这些——在项目根执行 git add -- onboard_files: Vec, /// status_porcelain 里不属于接入的改动条数(用户原有的未提交改动等):不要混进接入提交 other_changes: usize, }, Unavailable { reason: String, }, } /// 一条待提交的接入文件。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PendingFile { /// 相对项目根,统一用 / pub path: String, /// porcelain 的两位状态码(?? 未跟踪、" M" 已修改等) pub code: String, } /// HEAD 不变断言:一键接入不提交,写入前后 HEAD 相同(仓库尚无提交时两者都是 null)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HeadCheck { pub before: Option, pub after: Option, pub unchanged: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OnboardReport { pub project_id: String, pub status: OnboardStatus, /// 一行结论(给人和 agent 读) pub summary: String, /// failed / refused / busy 的原因 #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, /// 本次应用的计划 digest;refused 时为重算出的当前 digest #[serde(default, skip_serializing_if = "Option::is_none")] pub digest: Option, /// refused(digest 不符或有阻断项)时附上重算的当前计划,供重新给人确认 #[serde(default, skip_serializing_if = "Option::is_none")] pub plan: Option, /// 计划的阻断项(refused 因阻断项时非空) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocked: Vec, /// 本次向项目 .claude/settings.json 新写入了 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列) pub bypass_permissions: bool, pub steps: Vec, pub completion: Vec, /// L1 零模型三端矩阵(每格带证据等级);客户端步骤未完成的一端整列 blocked(dep=<步骤>) #[serde(default, skip_serializing_if = "Option::is_none")] pub l1: Option, /// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e) pub l3: String, /// 本次一键接入实际写入的项目文件(相对项目根,按写入顺序);未执行写入时为空 #[serde(default)] pub written_files: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub repo_changes: Option, /// 非 git 仓库为 null #[serde(default, skip_serializing_if = "Option::is_none")] pub head: Option, /// 有待提交的接入文件(repo_changes.onboard_files)时的建议提交信息(Conventional Commits),只针对这些文件; /// 提交由人或经用户同意的子项目 agent 完成(next_actions 的 commit 一项给出只 add 这些文件的命令) #[serde(default, skip_serializing_if = "Option::is_none")] pub suggested_commit_message: Option, pub next_actions: Vec, pub duration_ms: u64, } const L3_NOTE: &str = "L3 无头会话探针尚未提供(P4d/P4e),无最近结果;L1 只到 protocol 证据等级,\ 客户端版本变化时应以 L3 复核,桌面 GUI 形态归人工终验"; impl OnboardReport { /// 未执行任何写入就结束(failed / refused / busy)。 pub fn stopped( project_id: &str, status: OnboardStatus, reason: impl Into, started: Instant, ) -> Self { let reason = reason.into(); Self { project_id: project_id.to_string(), status, summary: format!("{}:{reason}", status_label(status)), reason: Some(reason), digest: None, plan: None, blocked: Vec::new(), bypass_permissions: false, steps: Vec::new(), completion: Vec::new(), l1: None, l3: L3_NOTE.into(), written_files: Vec::new(), repo_changes: None, head: None, suggested_commit_message: None, next_actions: Vec::new(), duration_ms: started.elapsed().as_millis() as u64, } } /// 重算的计划与确认的不一致或有阻断项:拒绝,附上当前计划供重新确认。 pub fn refused_with_plan( project_id: &str, reason: impl Into, plan: OnboardingPlan, started: Instant, ) -> Self { let mut report = Self::stopped(project_id, OnboardStatus::Refused, reason, started); report.digest = Some(plan.digest.clone()); report.blocked = plan.blocked.clone(); report.bypass_permissions = plan.bypass_permissions; report.next_actions = plan.next_actions.clone(); report.plan = Some(plan); report } } fn status_label(status: OnboardStatus) -> &'static str { match status { OnboardStatus::Done => "done", OnboardStatus::Partial => "partial", OnboardStatus::Failed => "failed", OnboardStatus::Refused => "refused", OnboardStatus::Busy => "busy", } } /// verify_onboarding 中不计入「完成」、未就绪时进入 next_actions 的检查(check 名, action, 说明)。 /// 名称须与 commands::onboarding::verify_onboarding 一致——由 apply 测试在真实检查链上核对,改名即红。 pub(crate) const NEXT_ACTION_CHECKS: [(&str, &str, &str); 5] = [ ( "origin 指向 Gitea", "gitea_repo", "在 Gitea 建仓并设为 origin,再配 webhook:工具默认建公开仓库(setup_gitea_repo 的 private 默认 false),\ 私有项目建仓时须显式传 private=true", ), ( "CI workflow 存在", "ci", "先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入", ), ( "飞轮 commit 数据流入", "flywheel", "首次提交并有 commit 活动后由 buff 轮询采集(或调 poll_now);新项目暂无数据不算接入失败", ), ( "Node/pnpm 工具链 resolver", "toolchain", "开始运行 pnpm 前调 get_toolchain_status(project_id);需要安装时经用户确认后调 prepare_toolchain(confirmed=true)", ), ( "production delivery profile", "delivery_profile", "项目自有声明 .lian-jing/capabilities.yaml 需由项目修复(接入包不覆盖)", ), ]; /// 一次已执行写入的接入,汇总成报告所需的全部事实(apply 收集,报告只做判定与组装)。 pub(crate) struct AppliedFacts { pub project_id: String, pub digest: String, pub bypass_permissions: bool, pub plan_next_actions: Vec, pub steps: Vec, /// verify_onboarding 的(检查名, 是否通过, 说明) pub l0_checks: Vec<(String, bool, String)>, /// apply 后各端的只读判定 pub clients_after: Vec<(ClientId, ClientOutcome)>, pub l1: L1Report, pub repo_changes: RepoChanges, pub written_files: Vec, pub head: Option, pub repo_layout: RepoLayout, pub started: Instant, } pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { let mut completion = Vec::new(); let mut next_actions = facts.plan_next_actions; for (name, pass, detail) in &facts.l0_checks { match NEXT_ACTION_CHECKS .iter() .find(|(check, _, _)| check == name) { Some((_, action, hint)) => { if !pass { next_actions.push(NextAction { action: (*action).into(), detail: format!("{hint}(当前:{detail})"), }); } } None => completion.push(CompletionItem::new( name.clone(), "l0", *pass, detail.clone(), )), } } for (client, outcome) in &facts.clients_after { completion.push(CompletionItem::new( client_label(*client), "clients", outcome.is_settled(), outcome.describe(), )); } let (l1_ready, awaiting_trust) = l1_readiness(&facts.l1); completion.push(CompletionItem::new( "L1 适用格", "l1", l1_ready, l1_summary(&facts.l1, awaiting_trust), )); if awaiting_trust > 0 { completion.push(CompletionItem { pending_user: true, ..CompletionItem::new( "Codex 信任", "l1", false, format!( "Codex 列 {awaiting_trust} 格仅因目录未受 Codex 信任而未就绪(codex_project_untrusted):Codex 只加载受信任目录的\ 项目 .codex/config.toml,信任由你在 Codex 里操作、炼境不写 Codex 信任表;不计入 done,信任后重跑一键接入\ 或 probe_client_parity 即就绪" ), ) }); if !next_actions .iter() .any(|action| action.action == "codex_trust") { next_actions.push(NextAction { action: "codex_trust".into(), detail: "L1 显示 Codex 在该目录看不到项目配置(目录未受信任):请在 Codex 里信任该目录(炼境不改 Codex 信任表),\ 之后新开的 Codex 会话才加载项目 .codex/config.toml" .into(), }); } } completion.push(match &facts.repo_changes { RepoChanges::Available { onboard_files, other_changes, .. } => CompletionItem::new( "项目文件改动可报告", "repo", true, format!( "{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}", onboard_files.len(), if *other_changes > 0 { format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add") } else { String::new() } ), ), RepoChanges::Unavailable { reason } => { CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone()) } }); if let Some(head) = &facts.head { completion.push(CompletionItem::new( "HEAD 未变", "repo", head.unchanged, if head.unchanged { "写入前后 HEAD 相同(炼境未提交)" } else { "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属" }, )); } let step = |name: &str| facts.steps.iter().find(|s| s.step == name); let applied = |name: &str| step(name).is_some_and(|s| s.status == StepStatus::Applied); if let Some(hooks) = step("git_hooks") { if let StepStatus::NeedsHuman { reason } = &hooks.status { next_actions.push(NextAction { action: "lefthook".into(), detail: format!("安装 lefthook 后重跑一键接入:{reason}"), }); } } let layout_action = repo_layout_next_action(&facts.repo_layout); let layout_blocks_done = layout_action.is_some(); next_actions.extend(layout_action); if applied("dsh") { next_actions.push(NextAction { action: "restart_dsh_gui".into(), detail: "DSH 托管块已变化:重启 DSH 桌面 GUI 后新开的会话才按工作区连项目地址".into(), }); } if applied("claude") { next_actions.push(NextAction { action: "claude_trust".into(), detail: "Claude Code 交互模式首次打开该目录时需确认 trust(-p 无头模式不受影响)" .into(), }); } if ["identity", "project_files", "claude", "codex", "dsh"] .iter() .any(|name| applied(name)) { // P4a 审查 ③:L1 的客户端自省不代表已打开的会话已重载,不据 reload_required 推断 next_actions.push(NextAction { action: "reopen_sessions".into(), detail: "本次改了项目配置或客户端注册:已打开的 Claude Code / Codex / DSH 会话不会自动加载,需重开;\ L1 是新起的检查进程,不代表已打开的会话已重载" .into(), }); } let commit_message = suggested_commit_message(&facts.repo_changes); if let ( Some(message), RepoChanges::Available { onboard_files, other_changes, .. }, ) = (&commit_message, &facts.repo_changes) { let paths: Vec = onboard_files .iter() .map(|file| shell_quote(&file.path)) .collect(); next_actions.push(NextAction { action: "commit".into(), detail: format!( "只提交接入文件:在项目根执行 git add -- {},再以「{message}」提交(由人或经用户同意的子项目 agent \ 按其 git 工作流完成,炼境不 add、不 commit){}", paths.join(" "), if *other_changes > 0 { format!(";另有 {other_changes} 处改动不属于接入(见 repo_changes),勿用 git add -A 一并提交") } else { String::new() } ), }); } let unsettled: Vec = facts .steps .iter() .filter(|s| !s.status.is_settled()) .map(|s| s.step.clone()) .collect(); // pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done let not_ready: Vec = completion .iter() .filter(|item| !item.ready && !item.pending_user) .map(|item| item.item.clone()) .collect(); let status = if unsettled.is_empty() && not_ready.is_empty() { OnboardStatus::Done } else { OnboardStatus::Partial }; let changed = commit_message.is_some(); let pending_note = if awaiting_trust > 0 { format!( ";Codex 列 {awaiting_trust} 格待你在 Codex 里信任该目录后就绪(completion 的 pending_user,不计入 done)" ) } else { String::new() }; let summary = match status { OnboardStatus::Done => format!( "done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)", facts.steps.len(), if changed { "接入文件有待提交改动(炼境不提交,只 add onboard_files);" } else { "" }, next_actions.len() ), _ => format!( "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}", unsettled.join("、"), not_ready.join("、"), if layout_blocks_done { ";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout" } else { "" } ), }; OnboardReport { project_id: facts.project_id, status, summary, reason: None, digest: Some(facts.digest), plan: None, blocked: Vec::new(), bypass_permissions: facts.bypass_permissions, steps: facts.steps, completion, l1: Some(facts.l1), l3: L3_NOTE.into(), written_files: facts.written_files, suggested_commit_message: commit_message, repo_changes: Some(facts.repo_changes), head: facts.head, next_actions, duration_ms: facts.started.elapsed().as_millis() as u64, } } fn client_label(client: ClientId) -> &'static str { match client { ClientId::Claude => "Claude local 条目", ClientId::Codex => "Codex 托管块", ClientId::Dsh => "DSH 白名单", } } fn l1_summary(report: &L1Report, awaiting_trust: usize) -> String { let cells: Vec<&Cell> = report .clients .iter() .flat_map(|column| column.cells.values()) .collect(); let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count(); format!( "{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms{}", report.clients.len(), cells.len(), bad, report.duration_ms, if awaiting_trust > 0 { format!( ";其中 {awaiting_trust} 格仅因 Codex 未信任该目录,单列「Codex 信任」、不计入本项" ) } else { String::new() } ) } /// L1 的「完成」口径(统筹决定,P8d 审查 ⑩):返回(计入 done 的格是否全部就绪, 只等 Codex 信任的格数)。 /// 只等 Codex 信任 = Codex 列里归因为 codex_project_untrusted 的未就绪格;其余未就绪格(含 Codex 列的其他归因)照常计入。 /// L1Report.all_applicable_ready 保持 L1 自身的原始判定,不在这里改写。 fn l1_readiness(report: &L1Report) -> (bool, usize) { let mut ready = true; let mut awaiting_trust = 0; for column in &report.clients { for cell in column.cells.values().filter(|cell| !cell.is_acceptable()) { if column.client == ClientId::Codex && cell.cause == Some(Cause::CodexProjectUntrusted) { awaiting_trust += 1; } else { ready = false; } } } (ready, awaiting_trust) } /// 项目根不是独立仓库根时说明为何到不了 done(next_action repo_layout);仓库根与非 git 目录不给 /// (非 git 由「项目文件改动可报告」未就绪说明)。 fn repo_layout_next_action(layout: &RepoLayout) -> Option { const WHY: &str = "verify_onboarding 的「lefthook hook 挂载」只认项目根 .git/hooks/pre-commit,这一项不会就绪,\ 所以本项目按当前检查口径到不了 done(三端可用不受影响);「origin 指向 Gitea」同样只读项目根 .git/config,\ gitea_repo 一项请按所在仓库的实际 origin 判断"; let unknown = |text: &str| { if text.is_empty() { "(未能取得)".to_string() } else { text.to_string() } }; let detail = match layout { RepoLayout::NotGit | RepoLayout::RepoRoot => return None, RepoLayout::Subdirectory { repo_root } => format!( "项目根是 git 仓库 {} 的子目录:hook 归外层仓库管理,炼境不改写外层仓库的 hooks(git_hooks 记 not_applicable);\ {WHY}。要到 done:在仓库根登记并接入,或让该目录自成仓库", unknown(repo_root) ), RepoLayout::GitFile { hooks_dir } => format!( "项目根的 .git 是文件(git worktree 或子模块),hook 实际在 {};{WHY},且 git_hooks 步每次都会重跑 lefthook install。\ 要到 done:worktree 请在主仓库根登记并接入(worktree 与主仓库共用 hooks)", unknown(hooks_dir) ), }; Some(NextAction { action: "repo_layout".into(), detail, }) } /// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计): /// 有新文件记「接入」,只有修改记「收敛」。 fn suggested_commit_message(changes: &RepoChanges) -> Option { let RepoChanges::Available { onboard_files, .. } = changes else { return None; }; if onboard_files.is_empty() { return None; } Some(if onboard_files.iter().any(|file| file.code == "??") { "chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)".into() } else { "chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into() }) } /// git add 命令行里的路径:含空白或引号时加双引号(接入文件路径本身不含这些字符,只为稳妥)。 fn shell_quote(path: &str) -> String { if path .chars() .any(|c| c.is_whitespace() || c == '"' || c == '\'') { format!("\"{}\"", path.replace('"', "\\\"")) } else { path.to_string() } } /// 一键接入结束后补写的 mcp_inject 事件(level, message):get_diagnostics「MCP 注入状态」与设置页近 7 天注入告警 /// 只认 category=mcp_inject(每个项目取最后一条),一键接入做了等同注入的工作,不补写会让旧的注入失败记录一直 /// 显示、新项目没有注入记录(P8d 审查 ④)。未执行写入(failed / refused / busy)不写;token、shim 与三端步骤 /// 都已完成记 info,否则记 warn 并列出未完成的步骤。 pub fn inject_event(report: &OnboardReport) -> Option<(&'static str, String)> { const INJECT_STEPS: [&str; 5] = ["token", "shim", "claude", "codex", "dsh"]; if report.steps.is_empty() { return None; } let unsettled: Vec = report .steps .iter() .filter(|step| INJECT_STEPS.contains(&step.step.as_str()) && !step.status.is_settled()) .map(|step| format!("{}({})", step.step, step.detail)) .collect(); Some(if unsettled.is_empty() { ( "info", "MCP 配置注入成功(一键接入:token、稳定入口与三端客户端步骤均已完成)".into(), ) } else { ( "warn", format!("一键接入注入未完成: {}", unsettled.join(";")), ) }) } // ── git 只读观测(--no-optional-locks:不抢 index.lock,避免与子项目里的 agent 提交冲突)── fn git_stdout(root: &Path, args: &[&str]) -> Result { let output = crate::silent_cmd("git") .arg("--no-optional-locks") .args(args) .current_dir(root) .output() .map_err(|error| format!("无法执行 git: {error}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } else { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } /// 根目录在 git 工作区内。 pub(crate) fn is_git_repo(root: &Path) -> bool { git_stdout(root, &["rev-parse", "--is-inside-work-tree"]).is_ok_and(|out| out.trim() == "true") } /// 项目根在 git 里的位置(只读):.git 为目录是仓库根,为文件是 worktree 或子模块,不存在而在工作区内是子目录。 pub(crate) fn repo_layout(root: &Path) -> RepoLayout { if !is_git_repo(root) { return RepoLayout::NotGit; } let trimmed = |result: Result| { result.map(|out| out.trim().to_string()).unwrap_or_default() }; let dot_git = root.join(".git"); if dot_git.is_dir() { RepoLayout::RepoRoot } else if dot_git.is_file() { RepoLayout::GitFile { hooks_dir: trimmed(git_stdout( root, &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], )), } } else { RepoLayout::Subdirectory { repo_root: trimmed(git_stdout(root, &["rev-parse", "--show-toplevel"])), } } } /// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。 pub(crate) fn git_head(root: &Path) -> Option { git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"]) .ok() .map(|out| out.trim().to_string()) .filter(|head| !head.is_empty()) } pub(crate) fn head_check(root: &Path, before: Option) -> Option { if !is_git_repo(root) { return None; } let after = git_head(root); Some(HeadCheck { unchanged: before == after, before, after, }) } /// 项目根目录内的待提交改动(pathspec「-- .」限定在项目根,外层仓库的其他改动不列),并按接入归属分类: /// written 为本次写入的文件,managed 为接入写入序列判过存在、读过或写过的文件(均相对项目根)。 /// 接入文件 = 本次写入的 + 写入序列管理且仍未跟踪的(多为之前的一键接入新建、尚未提交); /// 写入序列管理但本次没写、又是已跟踪文件的改动,来源无法区分,归入其他改动(宁可少列,不把用户改动混进接入提交)。 pub(crate) fn repo_changes( root: &Path, written: &[String], managed: &std::collections::BTreeSet, ) -> RepoChanges { if !is_git_repo(root) { return RepoChanges::Unavailable { reason: "不是 git 仓库:无法用 git status 报告待提交改动".into(), }; } let observed = (|| -> Result<(String, String, String, String), String> { Ok(( git_stdout( root, &["status", "--porcelain", "--untracked-files=all", "--", "."], )?, git_stdout(root, &["diff", "--stat", "--", "."])?, git_stdout(root, &["rev-parse", "--show-prefix"])?, git_stdout( root, &[ "status", "--porcelain", "-z", "--untracked-files=all", "--", ".", ], )?, )) })(); let (status, diff, prefix, entries) = match observed { Ok(observed) => observed, Err(error) => { return RepoChanges::Unavailable { reason: format!("git 读取改动失败: {error}"), } } }; let prefix = prefix.trim(); let mut onboard_files = Vec::new(); let mut other_changes = 0; for (code, path) in parse_porcelain_z(&entries) { let relative = path.strip_prefix(prefix).unwrap_or(&path).to_string(); let onboard = written.contains(&relative) || (code == "??" && managed.contains(&relative)); if onboard { onboard_files.push(PendingFile { path: relative, code, }); } else { other_changes += 1; } } RepoChanges::Available { status_porcelain: status .lines() .filter(|line| !line.trim().is_empty()) .map(str::to_string) .collect(), diff_stat: diff.trim_end().to_string(), onboard_files, other_changes, } } /// `git status --porcelain -z` → (两位状态码, 相对仓库根的路径)。改名与复制条目后面跟一个原路径,跳过。 fn parse_porcelain_z(output: &str) -> Vec<(String, String)> { let mut entries = Vec::new(); let mut tokens = output.split('\0').filter(|token| !token.is_empty()); while let Some(token) = tokens.next() { let (Some(code), Some(path)) = (token.get(..2), token.get(3..)) else { continue; }; if code.contains(['R', 'C']) { tokens.next(); } entries.push((code.to_string(), path.to_string())); } entries } #[cfg(test)] mod tests { use super::*; use crate::onboard::clients::NotApplicableReason; #[test] fn client_outcomes_map_to_step_statuses_and_settledness() { let cases = [ (ClientOutcome::Converged, "converged", true), (ClientOutcome::Applied, "applied", true), (ClientOutcome::Refreshed, "applied", true), (ClientOutcome::ManualKept, "skipped", true), (ClientOutcome::NotInstalled, "not_applicable", true), ( ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }, "not_applicable", true, ), ( ClientOutcome::OtherProject { owner: "x".into() }, "needs_human", false, ), ( ClientOutcome::Unavailable { reason: "claude CLI 未找到".into(), }, "needs_human", false, ), (ClientOutcome::Missing, "failed", false), ( ClientOutcome::Failed { reason: "boom".into(), }, "failed", false, ), ]; for (outcome, status, settled) in cases { let mapped = StepStatus::from_client(&outcome); let json = serde_json::to_value(StepReport::new("claude", mapped.clone(), "d")).unwrap(); assert_eq!(json["status"], status, "{outcome:?}"); assert_eq!(mapped.is_settled(), settled, "{outcome:?}"); } assert_eq!( StepStatus::from_client(&ClientOutcome::NotApplicable { reason: NotApplicableReason::Wsl }), StepStatus::NotApplicable { reason: "wsl".into() } ); } #[test] fn step_report_serializes_flat() { let report = StepReport::new( "codex", StepStatus::Blocked { dep: "token".into(), }, "依赖的 token 步骤未完成", ); assert_eq!( serde_json::to_value(&report).unwrap(), serde_json::json!({ "step": "codex", "status": "blocked", "dep": "token", "detail": "依赖的 token 步骤未完成" }) ); assert_eq!(serde_json::to_value(OnboardStatus::Busy).unwrap(), "busy"); } #[test] fn head_check_flags_a_moved_head_and_skips_non_git() { let root = std::env::temp_dir().join(format!("lj_p8d_head_{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); assert!( head_check(&root, None).is_none(), "非 git 仓库不做 HEAD 断言" ); let git = |args: &[&str]| { let out = std::process::Command::new("git") .args([ "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ]) .args(args) .current_dir(&root) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); }; git(&["init", "-q"]); let unborn = head_check(&root, git_head(&root)).unwrap(); assert!(unborn.unchanged && unborn.before.is_none()); std::fs::write(root.join("a.txt"), "a\n").unwrap(); git(&["add", "-A"]); git(&["commit", "-q", "--no-verify", "-m", "a"]); let moved = head_check(&root, None).unwrap(); assert!(!moved.unchanged, "写入期间出现提交须如实报告"); let none = std::collections::BTreeSet::new(); assert_eq!( suggested_commit_message(&repo_changes(&root, &[], &none)), None, "无改动不给提交信息" ); std::fs::write(root.join("a.txt"), "b\n").unwrap(); // 不是接入写的改动:计入其他改动,不给接入提交信息(P8d 审查 ③) let foreign = repo_changes(&root, &[], &none); assert!( matches!(&foreign, RepoChanges::Available { onboard_files, other_changes: 1, .. } if onboard_files.is_empty()), "{foreign:?}" ); assert_eq!(suggested_commit_message(&foreign), None); // 本次写入的已跟踪文件 → 收敛 assert!( suggested_commit_message(&repo_changes(&root, &["a.txt".to_string()], &none)) .is_some_and(|message| message.starts_with("chore(lian-jing): 收敛")) ); // 写入序列管理、本次没写的已跟踪文件改动:来源无法区分,不算接入文件 let managed: std::collections::BTreeSet = ["a.txt".to_string()].into(); assert!(matches!( repo_changes(&root, &[], &managed), RepoChanges::Available { other_changes: 1, .. } )); let _ = std::fs::remove_dir_all(&root); } #[test] fn porcelain_z_parsing_skips_rename_sources() { assert_eq!( parse_porcelain_z("?? a b.md\0R new.md\0old.md\0 M sub/c.md\0"), [ ("??".to_string(), "a b.md".to_string()), ("R ".to_string(), "new.md".to_string()), (" M".to_string(), "sub/c.md".to_string()), ] ); assert_eq!(shell_quote("AGENTS.md"), "AGENTS.md"); assert_eq!(shell_quote("a b.md"), "\"a b.md\""); } /// 一键接入补写 mcp_inject 事件(P8d 审查 ④):未写入不记;注入相关步骤全部完成记 info,否则 warn 列出未完成的。 #[test] fn inject_event_follows_injection_steps_only() { let started = Instant::now(); let stopped = OnboardReport::stopped("p", OnboardStatus::Refused, "未确认", started); assert_eq!(inject_event(&stopped), None, "未执行写入不补写注入事件"); let mut report = stopped.clone(); report.status = OnboardStatus::Partial; report.steps = vec![ StepReport::new( "git_hooks", StepStatus::NeedsHuman { reason: "缺 lefthook".into(), }, "缺 lefthook", ), StepReport::new("token", StepStatus::Applied, "已生成"), StepReport::new("shim", StepStatus::Converged, "最新"), StepReport::new( "claude", StepStatus::Skipped { reason: "manual_kept".into(), }, "跳过", ), StepReport::new("codex", StepStatus::Applied, "已写入"), StepReport::new( "dsh", StepStatus::NotApplicable { reason: "not_installed".into(), }, "未安装", ), ]; let (level, message) = inject_event(&report).unwrap(); assert_eq!(level, "info", "与注入无关的 git_hooks 未完成不影响注入状态"); assert!(message.contains("注入成功"), "{message}"); report.steps.push(StepReport::new( "claude", StepStatus::Failed { reason: "CLI 失败".into(), }, "CLI 失败", )); let (level, message) = inject_event(&report).unwrap(); assert_eq!(level, "warn"); assert!(message.contains("claude"), "{message}"); } // ── 统筹决定(P8d 审查 ⑩):Codex 信任单列 pending_user、仓库布局说明 ────────────────────── use crate::onboard::probe_l1::{CellState, Check, ClientColumn, EvidenceLevel, ProbeProfile}; const CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F]; fn cell(state: CellState, cause: Option) -> Cell { Cell { state, cause, evidence: EvidenceLevel::Protocol, detail: String::new(), dep: None, } } /// 一端的 L1 列:未列出的检查项为 ready。 fn column(client: ClientId, overrides: &[(Check, CellState, Cause)]) -> ClientColumn { ClientColumn { client, version: None, route: None, cells: CHECKS .iter() .map(|check| { let value = overrides .iter() .find(|(name, _, _)| name == check) .map_or(cell(CellState::Ready, None), |(_, state, cause)| { cell(*state, Some(*cause)) }); (*check, value) }) .collect(), duration_ms: 1, } } fn l1(columns: Vec) -> L1Report { let all_applicable_ready = columns .iter() .all(|column| column.cells.values().all(Cell::is_acceptable)); L1Report { project_id: "p".into(), tier: "l1".into(), probe_spec_version: 1, profile: ProbeProfile::Project, expected_route: "http://127.0.0.1:27190/p/p/mcp".into(), expected_tools: 1, nonce: "n".into(), clients: columns, all_applicable_ready, duration_ms: 1, note: String::new(), } } const UNTRUSTED: [(Check, CellState, Cause); 4] = [ (Check::C, CellState::Fail, Cause::CodexProjectUntrusted), (Check::D, CellState::Fail, Cause::CodexProjectUntrusted), (Check::E, CellState::Fail, Cause::CodexProjectUntrusted), (Check::F, CellState::Fail, Cause::CodexProjectUntrusted), ]; /// 九步全部收敛、L0 与只读判定全部就绪的已执行写入;L1、仓库布局、额外 L0 检查与计划的 next_actions 由用例给出。 fn facts( l1: L1Report, repo_layout: RepoLayout, extra_l0: &[(&str, bool)], plan_next_actions: Vec, ) -> AppliedFacts { let mut l0_checks = vec![ ("AGENTS.md 管理块版本".to_string(), true, "ok".to_string()), ( "origin 指向 Gitea".to_string(), false, "无 origin".to_string(), ), ]; l0_checks.extend( extra_l0 .iter() .map(|(name, pass)| (name.to_string(), *pass, String::new())), ); AppliedFacts { project_id: "p".into(), digest: "d".into(), bypass_permissions: false, plan_next_actions, steps: [ "identity", "project_files", "git_hooks", "product_group_buff", "token", "shim", "claude", "codex", "dsh", ] .iter() .map(|name| StepReport::new(name, StepStatus::Converged, "一致")) .collect(), l0_checks, clients_after: [ClientId::Claude, ClientId::Codex, ClientId::Dsh] .into_iter() .map(|client| (client, ClientOutcome::Converged)) .collect(), l1, repo_changes: RepoChanges::Available { status_porcelain: Vec::new(), diff_stat: String::new(), onboard_files: Vec::new(), other_changes: 0, }, written_files: Vec::new(), head: Some(HeadCheck { before: Some("h".into()), after: Some("h".into()), unchanged: true, }), repo_layout, started: Instant::now(), } } fn ready_matrix(codex: &[(Check, CellState, Cause)]) -> L1Report { l1(vec![ column(ClientId::Claude, &[]), column(ClientId::Codex, codex), column(ClientId::Dsh, &[]), ]) } fn count_actions(report: &OnboardReport, name: &str) -> usize { report .next_actions .iter() .filter(|action| action.action == name) .count() } #[test] fn codex_trust_only_cells_are_pending_user_and_do_not_block_done() { let report = applied_report(facts( ready_matrix(&UNTRUSTED), RepoLayout::RepoRoot, &[], Vec::new(), )); assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); assert!( !report.l1.as_ref().unwrap().all_applicable_ready, "L1 原始判定保留" ); let l1_item = report .completion .iter() .find(|item| item.item == "L1 适用格") .unwrap(); assert!(l1_item.ready && !l1_item.pending_user); assert!( l1_item.detail.contains("4 格仅因 Codex 未信任"), "{}", l1_item.detail ); let pending: Vec<&CompletionItem> = report .completion .iter() .filter(|item| item.pending_user) .collect(); assert_eq!(pending.len(), 1); assert_eq!( ( pending[0].item.as_str(), pending[0].source.as_str(), pending[0].ready ), ("Codex 信任", "l1", false) ); assert_eq!(count_actions(&report, "codex_trust"), 1, "L1 的结论补一条"); assert!(report.summary.starts_with("done:"), "{}", report.summary); assert!( report.summary.contains("pending_user"), "{}", report.summary ); let json = serde_json::to_value(&pending[0]).unwrap(); assert_eq!(json["pending_user"], true, "pending_user 进入契约"); // 计划已列 codex_trust:不重复 let planned = applied_report(facts( ready_matrix(&UNTRUSTED), RepoLayout::RepoRoot, &[], vec![NextAction { action: "codex_trust".into(), detail: "计划预判".into(), }], )); assert_eq!(count_actions(&planned, "codex_trust"), 1); assert_eq!(planned.next_actions[0].detail, "计划预判"); // 全部就绪:没有 pending_user 项,也不补 codex_trust let clean = applied_report(facts( ready_matrix(&[]), RepoLayout::RepoRoot, &[], Vec::new(), )); assert_eq!(clean.status, OnboardStatus::Done); assert!(clean.completion.iter().all(|item| !item.pending_user)); assert_eq!(count_actions(&clean, "codex_trust"), 0); assert!(!clean.summary.contains("pending_user")); } #[test] fn other_not_ready_cells_still_block_done() { // Codex 列混入别的归因:只单列 codex_project_untrusted 的格,其余照常计入 let mut mixed = UNTRUSTED.to_vec(); mixed[3] = (Check::F, CellState::Fail, Cause::AuthFailed); let report = applied_report(facts( ready_matrix(&mixed), RepoLayout::RepoRoot, &[], Vec::new(), )); assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}"); assert!(report .completion .iter() .any(|item| item.item == "L1 适用格" && !item.ready)); assert!(report .completion .iter() .any(|item| item.pending_user && item.detail.contains("3 格"))); // 只认 Codex 列:别的列出现同一归因也照常计入 let other_column = l1(vec![ column( ClientId::Claude, &[(Check::C, CellState::Fail, Cause::CodexProjectUntrusted)], ), column(ClientId::Codex, &[]), column(ClientId::Dsh, &[]), ]); let report = applied_report(facts(other_column, RepoLayout::RepoRoot, &[], Vec::new())); assert_eq!(report.status, OnboardStatus::Partial); assert!(report.completion.iter().all(|item| !item.pending_user)); // blocked(依赖步骤未完成)的 Codex 格不是「只等信任」 let blocked = ready_matrix(&[(Check::C, CellState::Blocked, Cause::Dependency)]); let report = applied_report(facts(blocked, RepoLayout::RepoRoot, &[], Vec::new())); assert_eq!(report.status, OnboardStatus::Partial); // pending_user 只豁免 L1 的 Codex 信任:别的未就绪检查照常让 status=partial let report = applied_report(facts( ready_matrix(&UNTRUSTED), RepoLayout::RepoRoot, &[("Claude 炼境技能已分发", false)], Vec::new(), )); assert_eq!(report.status, OnboardStatus::Partial); assert!( report.summary.contains("Claude 炼境技能已分发"), "{}", report.summary ); assert!( report.summary.contains("pending_user"), "{}", report.summary ); } #[test] fn repo_layout_next_action_explains_why_done_is_unreachable() { let hooks_missing = [("lefthook hook 挂载", false)]; let subdir = applied_report(facts( ready_matrix(&[]), RepoLayout::Subdirectory { repo_root: "E:/work/outer".into(), }, &hooks_missing, Vec::new(), )); assert_eq!(subdir.status, OnboardStatus::Partial); let action = subdir .next_actions .iter() .find(|action| action.action == "repo_layout") .expect("仓库子目录给出 repo_layout"); for phrase in ["E:/work/outer", "子目录", "lefthook hook 挂载", "done"] { assert!( action.detail.contains(phrase), "缺「{phrase}」:{}", action.detail ); } assert!(subdir.summary.contains("repo_layout"), "{}", subdir.summary); let worktree = applied_report(facts( ready_matrix(&[]), RepoLayout::GitFile { hooks_dir: "E:/work/main/.git/hooks".into(), }, &hooks_missing, Vec::new(), )); let action = worktree .next_actions .iter() .find(|action| action.action == "repo_layout") .expect("worktree 给出 repo_layout"); for phrase in ["E:/work/main/.git/hooks", "worktree", "lefthook hook 挂载"] { assert!( action.detail.contains(phrase), "缺「{phrase}」:{}", action.detail ); } // 仓库根与非 git 目录不给(非 git 由「项目文件改动可报告」未就绪说明) for layout in [RepoLayout::RepoRoot, RepoLayout::NotGit] { let report = applied_report(facts(ready_matrix(&[]), layout, &[], Vec::new())); assert_eq!(count_actions(&report, "repo_layout"), 0); assert!(!report.summary.contains("repo_layout")); } } #[test] fn repo_layout_detects_root_subdirectory_worktree_and_non_git() { let base = std::env::temp_dir().join(format!("lj_p8e_layout_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let main = base.join("main"); let plain = base.join("plain"); std::fs::create_dir_all(main.join("sub")).unwrap(); std::fs::create_dir_all(&plain).unwrap(); let git = |dir: &Path, args: &[&str]| { let out = std::process::Command::new("git") .args([ "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ]) .args(args) .current_dir(dir) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); }; git(&main, &["init", "-q"]); std::fs::write(main.join("a.txt"), "a\n").unwrap(); git(&main, &["add", "-A"]); git(&main, &["commit", "-q", "--no-verify", "-m", "a"]); let worktree = base.join("wt"); git( &main, &[ "worktree", "add", "-q", "--detach", &worktree.display().to_string(), ], ); assert_eq!(repo_layout(&plain), RepoLayout::NotGit); assert_eq!(repo_layout(&main), RepoLayout::RepoRoot); let normalized = |text: &str| text.replace('\\', "/").to_lowercase(); match repo_layout(&main.join("sub")) { RepoLayout::Subdirectory { repo_root } => { assert!(normalized(&repo_root).ends_with("/main"), "{repo_root}") } other => panic!("仓库子目录应判为 Subdirectory:{other:?}"), } match repo_layout(&worktree) { RepoLayout::GitFile { hooks_dir } => assert!( normalized(&hooks_dir).ends_with("/main/.git/hooks"), "worktree 的 hook 在主仓库:{hooks_dir}" ), other => panic!("worktree 应判为 GitFile:{other:?}"), } let _ = std::fs::remove_dir_all(&base); } }