Merge pull request 'feat(onboard/parity): 一键接入 plan_onboarding / onboard_project 的 MCP 与 Tauri 投影,Codex 信任计 pending_user(P8e)' (#40) from feat/agent-client-parity-p8e into master
All checks were successful
Push & PR Check / check (push) Successful in 1m12s

This commit is contained in:
lanrtop 2026-10-03 21:14:06 +08:00
commit d7f481616a
12 changed files with 1657 additions and 77 deletions

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入) src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口) src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口)
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验;生产依赖由 mcp_inject 组装,MCP 工具与 Tauri command plan_onboarding / onboard_project 只一行委托)
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射) docs/ai-context/ AI 上下文文档(速查卡、契约映射)

View File

@ -137,6 +137,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
- 日常开发优先运行 `pnpm tauri dev`,Agent 直接消费同一 loopback MCP;正式安装包只在安装、自启动、升级或发布里程碑做一次验收,不作为每次提交的前置条件。 - 日常开发优先运行 `pnpm tauri dev`,Agent 直接消费同一 loopback MCP;正式安装包只在安装、自启动、升级或发布里程碑做一次验收,不作为每次提交的前置条件。
- 开发炼境前先从托盘完全退出正式安装版;不得把正式版与 `pnpm tauri dev` 并行运行。single-instance 是运行时兜底,不替代这一开发约定。 - 开发炼境前先从托盘完全退出正式安装版;不得把正式版与 `pnpm tauri dev` 并行运行。single-instance 是运行时兜底,不替代这一开发约定。
- updater 是独立的发布能力,不属于自启动或 readiness 的完成条件。 - updater 是独立的发布能力,不属于自启动或 readiness 的完成条件。
- 会改写子项目或本机配置的批量动作走「只读计划 → 人确认同一 digest → 应用」(一键接入 `plan_onboarding` → `onboard_project`,CI workflow `plan_cicd_workflow` → `apply_cicd_workflow`):agent 只能转达计划、不能替人确认;应用时重算计划,digest 不符即拒绝、零写入。炼境不 add、不 commit,Codex 信任、Gitea 建仓等列为 `next_actions` 由人完成。
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。 这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
@ -186,6 +187,7 @@ Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED | | 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED | | Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 | | MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 |
| 一键接入子项目 | MCP `plan_onboarding(project_id)` → 计划给用户看、用户确认 → `onboard_project(project_id, expected_digest=<计划 digest>, confirmed=true)`(两次选项相同;UI 投影为同名 Tauri command,同一本体) | 计划 `blocked` 为空;报告 `status=done`(`partial` 按 `steps`、`completion`、`next_actions` 修复后重跑即收敛;再跑一次全部 `converged`) |
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 | | 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 | | 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |

File diff suppressed because one or more lines are too long

View File

@ -488,6 +488,44 @@ pub async fn probe_client_parity(
.map_err(|error| format!("L1 检查线程异常: {error}"))? .map_err(|error| format!("L1 检查线程异常: {error}"))?
} }
// ── 一键接入(agent-client-parity P8e):UI 投影,与 MCP plan_onboarding / onboard_project 同一本体 ──────
// 用 async fn + spawn_blocking(同 probe_client_parity),不用 #[tauri::command(async)] 包同步函数:后者让函数体占住一个
// tokio worker,而 MCP 服务端跑在同一运行时、一键接入可能 30 秒以上(P8d 审查 ⑨)。options 缺省即默认选项;
// 计划与应用须传同一组选项,否则 digest 不符被拒。
/// 一键接入写入前计划(只读)。
#[tauri::command]
pub async fn plan_onboarding(
project_id: String,
options: Option<crate::onboard::apply::OnboardOptions>,
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
tokio::task::spawn_blocking(move || {
crate::mcp_inject::plan_onboarding(&project_id, &options.unwrap_or_default())
})
.await
.map_err(|error| format!("一键接入计划线程异常: {error}"))?
}
/// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写;结果以报告的 status 区分(不以 Err 表示 refused 等)。
#[tauri::command]
pub async fn onboard_project(
project_id: String,
expected_digest: String,
confirmed: bool,
options: Option<crate::onboard::apply::OnboardOptions>,
) -> Result<crate::onboard::report::OnboardReport, String> {
tokio::task::spawn_blocking(move || {
crate::mcp_inject::onboard_project(
&project_id,
&expected_digest,
confirmed,
&options.unwrap_or_default(),
)
})
.await
.map_err(|error| format!("一键接入线程异常: {error}"))
}
// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
/// 单项验收结果。 /// 单项验收结果。

View File

@ -443,6 +443,8 @@ pub fn run() {
// onboarding pack // onboarding pack
commands::onboarding::apply_onboarding_pack, commands::onboarding::apply_onboarding_pack,
commands::onboarding::probe_client_parity, commands::onboarding::probe_client_parity,
commands::onboarding::plan_onboarding,
commands::onboarding::onboard_project,
// agent infrastructure // agent infrastructure
commands::agent_infra::scan_agent_infra_stack, commands::agent_infra::scan_agent_infra_stack,
commands::agent_infra::generate_agent_infra_files, commands::agent_infra::generate_agent_infra_files,

View File

@ -1701,7 +1701,8 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ──────────────────────────────────── // ── 一键接入(P8c 计划 / P8d 编排):生产入口 ────────────────────────────────────
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。 // 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。 // 对外投影(P8e):MCP 工具 plan_onboarding / onboard_project(mcp_tools)与同名 Tauri command
// (commands::onboarding)都只在这两个入口上做一行委托,不在投影里复制任何判定。
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 /// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
/// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源): /// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源):
@ -1795,8 +1796,6 @@ fn with_onboard_deps<T>(
/// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的 /// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的
/// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。 /// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。
/// P8e 接入 MCP / Tauri 投影前暂无生产调用方。
#[allow(dead_code)]
pub(crate) fn plan_onboarding( pub(crate) fn plan_onboarding(
project_id: &str, project_id: &str,
options: &crate::onboard::apply::OnboardOptions, options: &crate::onboard::apply::OnboardOptions,
@ -1808,7 +1807,6 @@ pub(crate) fn plan_onboarding(
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。 /// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的 /// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。 /// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
#[allow(dead_code)]
pub(crate) fn onboard_project( pub(crate) fn onboard_project(
project_id: &str, project_id: &str,
expected_digest: &str, expected_digest: &str,
@ -1859,7 +1857,6 @@ fn codex_home() -> Option<PathBuf> {
/// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任, /// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任,
/// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。 /// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。
/// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。 /// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。
#[allow(dead_code)]
fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust { fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
use crate::onboard::plan::CodexTrust; use crate::onboard::plan::CodexTrust;
let Some(home) = codex_home().filter(|dir| dir.is_dir()) else { let Some(home) = codex_home().filter(|dir| dir.is_dir()) else {
@ -1882,7 +1879,6 @@ fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
/// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根, /// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根,
/// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。 /// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。
#[allow(dead_code)]
fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> { fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
let output = crate::silent_cmd("git") let output = crate::silent_cmd("git")
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
@ -1896,7 +1892,6 @@ fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
PathBuf::from(common.trim()).parent().map(PathBuf::from) PathBuf::from(common.trim()).parent().map(PathBuf::from)
} }
#[allow(dead_code)]
fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool { fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool {
let trusted = codex_trusted_projects(config); let trusted = codex_trusted_projects(config);
candidates candidates

View File

@ -312,7 +312,7 @@ pub fn tools_list_result() -> Value {
}, },
{ {
"name": "list_all_projects", "name": "list_all_projects",
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。", "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再用 plan_onboarding 预览、经用户确认后 onboard_project 一键接入(inject_mcp 只做本机 MCP 注入)。",
"inputSchema": { "type": "object", "properties": {}, "required": [] } "inputSchema": { "type": "object", "properties": {}, "required": [] }
}, },
{ {
@ -410,11 +410,131 @@ pub fn tools_list_result() -> Value {
.position(|tool| tool["name"] == "scan_unregistered_projects") .position(|tool| tool["name"] == "scan_unregistered_projects")
.map_or(tools.len(), |index| index + 1); .map_or(tools.len(), |index| index + 1);
tools.insert(after_scan, register_project_schema()); tools.insert(after_scan, register_project_schema());
// 一键接入排在旧的分步入口 apply_onboarding_pack 之前:新接入走 plan → 用户确认 → onboard_project
let before_pack = tools
.iter()
.position(|tool| tool["name"] == "apply_onboarding_pack")
.unwrap_or(tools.len());
tools.splice(
before_pack..before_pack,
[plan_onboarding_schema(), onboard_project_schema()],
);
} }
apply_tool_annotations(&mut result); apply_tool_annotations(&mut result);
result result
} }
/// plan_onboarding 与 onboard_project 共用的选项参数(与 onboard::apply::OnboardOptions 同名同义;两次调用必须一致,
/// 否则 digest 不符被拒)。
fn add_onboard_option_properties(schema: &mut Value) {
let properties = &mut schema["inputSchema"]["properties"];
properties["bypass_permissions"] = json!({
"type": "boolean",
"description": "可选,默认 true(保持现状):项目 .claude/settings.json 还没有 permissions 时写入 permissions.defaultMode=bypassPermissions;false 即按项目取消,改写显式 defaultMode=default 并被记住。plan_onboarding 与 onboard_project 必须传同一值"
});
properties["skip_targets"] = json!({
"type": "array",
"items": { "type": "string" },
"description": "可选:用户在确认时逐项选择「跳过」的阻断项 target(取 plan_onboarding 返回的 blocked[].target)。只有 reason 为 manual_entry(手写同名条目)或 shared_key(共享项目键)的阻断项可跳过——跳过即保留现状、该端不写;identity_conflict、mixed_file、needs_human 列进来也照旧阻断。plan_onboarding 与 onboard_project 必须传同一组"
});
}
/// 一键接入第一步(agent-client-parity P8e,单独成函数:并入主清单会超出 json! 宏递归上限)。
fn plan_onboarding_schema() -> Value {
let mut schema = json!({
"name": "plan_onboarding",
"description": "一键接入第一步(只读,等价 UI「一键接入」的预览):演算对项目的全部接入动作并返回计划——将写入的项目根目录 root、逐文件 create/update/noop 与有上限的 diff、本机七步预计动作 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、阻断项 blocked 与已跳过项 skipped、是否会新写入 bypassPermissions、Codex 信任 codex_trust、next_actions 与 digest。不写文件、不写 DB、不起客户端 CLI。拿到计划后先给用户看(列出要改的文件、blocked、bypass_permissions 与 next_actions),用户明确确认后再带同一 digest(expected_digest)与 confirmed=true 调 onboard_project;bypass_permissions 与 skip_targets 两次调用必须一致,否则 digest 不符被拒。blocked 非空时 onboard_project 一律拒绝:manual_entry 与 shared_key 经用户同意可放进 skip_targets 重新计划,其余须先处理再重新计划。Codex 只在用户在 Codex 里信任该目录后才加载项目配置(未信任时 codex_trust 列入 next_actions,炼境不写 Codex 信任表)。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 或 register_project 的回执)" }
},
"required": ["project_id"]
}
});
add_onboard_option_properties(&mut schema);
schema
}
/// 一键接入第二步(agent-client-parity P8e,单独成函数:并入主清单会超出 json! 宏递归上限)。
fn onboard_project_schema() -> Value {
let mut schema = json!({
"name": "onboard_project",
"description": "一键接入第二步(写操作,等价 UI「一键接入」的确认):只在用户明确确认 plan_onboarding 的计划后调用,带回该计划的 digest(expected_digest)、confirmed=true 以及与计划时相同的 bypass_permissions / skip_targets。炼境在接入写锁内重算计划:未确认、digest 不符或仍有阻断项即 refused 且零写入(附当前计划,须重新给用户确认);同一项目已有接入在执行返回 busy;未登记或路径无效返回 failed。之后依次收敛项目身份与接入包文件(规则、skill 双投影、Codex 托管块)、产品组与 buff、本机 token 与稳定入口、Claude local / Codex / DSH 三端注册与 lefthook,再跑 L1 零模型三端检查;返回分步结果 steps、完成判定 completion、L1 矩阵、本次写入与待提交的接入文件(炼境不 add、不 commit)、建议提交信息与 next_actions。以 status 判断结果:done(三端可用子集全部就绪)/ partial(不自动回滚,修复后重跑即收敛)/ failed / refused / busy。重复执行收敛(全部 converged、不改文件);首次可能耗时 30 秒以上。next_actions 由人完成、炼境不代做:Codex 需用户在 Codex 里信任该目录才加载项目配置(codex_trust;仅因此未就绪的 Codex 格单列 pending_user、不阻断 done);Gitea 建仓(setup_gitea_repo)默认建公开仓库,私有项目须显式 private=true。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(与 plan_onboarding 相同)" },
"expected_digest": { "type": "string", "description": "用户确认时看到的 plan_onboarding 返回的 digest" },
"confirmed": { "type": "boolean", "description": "用户是否已明确确认该计划(要改的文件、本机变更与 bypassPermissions);false 时不写入" }
},
"required": ["project_id", "expected_digest", "confirmed"]
}
});
add_onboard_option_properties(&mut schema);
schema
}
/// plan_onboarding / onboard_project 的选项参数:只做 JSON 类型解析,缺省取 OnboardOptions::default;
/// 哪些阻断项可跳过、选项不一致即 digest 不符等语义由本体判定(边界单点,投影不复制)。
fn onboard_options(args: &Value) -> Result<crate::onboard::apply::OnboardOptions, String> {
let mut options = crate::onboard::apply::OnboardOptions::default();
match args.get("bypass_permissions") {
None | Some(Value::Null) => {}
Some(value) => {
options.bypass_permissions = value
.as_bool()
.ok_or_else(|| "bypass_permissions 必须为布尔值".to_string())?
}
}
match args.get("skip_targets") {
None | Some(Value::Null) => {}
Some(Value::Array(items)) => {
options.skip_targets = items
.iter()
.map(|item| {
item.as_str().map(str::to_string).ok_or_else(|| {
"skip_targets 的每一项必须为字符串(plan_onboarding 返回的 blocked[].target)"
.to_string()
})
})
.collect::<Result<_, _>>()?
}
Some(_) => return Err("skip_targets 必须为字符串数组".to_string()),
}
Ok(options)
}
/// plan_onboarding 的分发:参数名与 [`plan_onboarding_schema`] 一一对应,本体在 mcp_inject::plan_onboarding。
fn plan_onboarding_call(args: &Value) -> Result<String, String> {
let options = onboard_options(args)?;
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let plan = crate::mcp_inject::plan_onboarding(project_id, &options)?;
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())
}
/// onboard_project 的分发:参数名与 [`onboard_project_schema`] 一一对应,本体在 mcp_inject::onboard_project。
/// 未确认、digest 不符等都由本体判定并以报告 status 返回(refused 附当前计划),这里只做类型解析。
fn onboard_project_call(args: &Value) -> Result<String, String> {
let options = onboard_options(args)?;
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let expected_digest = match args.get("expected_digest") {
None | Some(Value::Null) => "",
Some(value) => value
.as_str()
.ok_or_else(|| "expected_digest 必须为字符串".to_string())?,
};
let confirmed = match args.get("confirmed") {
None | Some(Value::Null) => false,
Some(value) => value
.as_bool()
.ok_or_else(|| "confirmed 必须为布尔值".to_string())?,
};
let report =
crate::mcp_inject::onboard_project(project_id, expected_digest, confirmed, &options);
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())
}
/// 三端对等 L1 零模型检查(agent-client-parity P4a)。单独成函数:并入主清单会超出 json! 宏递归上限。 /// 三端对等 L1 零模型检查(agent-client-parity P4a)。单独成函数:并入主清单会超出 json! 宏递归上限。
/// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。 /// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。
fn parity_probe_tool_schema() -> Value { fn parity_probe_tool_schema() -> Value {
@ -654,6 +774,7 @@ fn apply_tool_annotations(result: &mut Value) {
name, name,
"inject_mcp" "inject_mcp"
| "apply_onboarding_pack" | "apply_onboarding_pack"
| "onboard_project"
| "setup_webhook" | "setup_webhook"
| "sync_blueprint_rules" | "sync_blueprint_rules"
| "backfill_notes_archive" | "backfill_notes_archive"
@ -796,6 +917,8 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
.unwrap_or("接入验收结构化结果已返回") .unwrap_or("接入验收结构化结果已返回")
.to_string(), .to_string(),
"probe_client_parity" => parity_probe_summary(value), "probe_client_parity" => parity_probe_summary(value),
"plan_onboarding" => onboarding_plan_summary(value),
"onboard_project" => onboard_report_summary(value),
"register_project" => { "register_project" => {
let summary = format!( let summary = format!(
"已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project",
@ -866,6 +989,112 @@ fn parity_probe_summary(value: &Value) -> String {
) )
} }
/// 一键接入计划的一行摘要(结构化计划在 structuredContent):文件动作计数、本机七步、阻断与跳过、
/// bypassPermissions、Codex 信任与 digest,以及下一步该怎么做。
fn onboarding_plan_summary(value: &Value) -> String {
let files = value["files"].as_array();
let count = |action: &str| {
files.map_or(0, |files| {
files.iter().filter(|file| file["action"] == action).count()
})
};
let machine: Vec<String> = value["machine_changes"]
.as_array()
.map(|steps| {
steps
.iter()
.map(|step| {
format!(
"{}={}",
step["step"].as_str().unwrap_or("?"),
step["status"].as_str().unwrap_or("?")
)
})
.collect()
})
.unwrap_or_default();
let blocked: Vec<String> = value["blocked"]
.as_array()
.map(|items| {
items
.iter()
.map(|item| {
format!(
"{}({})",
item["target"].as_str().unwrap_or("?"),
item["reason"].as_str().unwrap_or("?")
)
})
.collect()
})
.unwrap_or_default();
let skipped = value["skipped"].as_array().map_or(0, Vec::len);
format!(
"一键接入计划:project_id={},根目录 {};文件 create {} / update {} / noop {};本机 {};阻断 {} 项{}{};\
bypassPermissions 新写入={};Codex 信任={};digest={}。{}",
value["project_id"].as_str().unwrap_or("?"),
value["root"].as_str().unwrap_or("?"),
count("create"),
count("update"),
count("noop"),
machine.join(" "),
blocked.len(),
if blocked.is_empty() {
String::new()
} else {
format!("({})", blocked.join("、"))
},
if skipped > 0 {
format!(",已按选项跳过 {skipped} 项")
} else {
String::new()
},
if value["bypass_permissions"] == true {
"是"
} else {
"否"
},
value["codex_trust"].as_str().unwrap_or("?"),
value["digest"].as_str().unwrap_or("?"),
if blocked.is_empty() {
"须先把计划给用户看,用户明确确认后带 expected_digest 与 confirmed=true(及相同选项)调 onboard_project"
} else {
"有阻断项,onboard_project 会拒绝:manual_entry / shared_key 经用户同意可放进 skip_targets 重新计划,其余须先处理再重新计划"
}
)
}
/// 一键接入报告的一行摘要:本体给出的 summary(以 status 开头),加待提交接入文件数与 next_actions 名。
fn onboard_report_summary(value: &Value) -> String {
let actions: Vec<&str> = value["next_actions"]
.as_array()
.map(|actions| {
actions
.iter()
.filter_map(|action| action["action"].as_str())
.collect()
})
.unwrap_or_default();
let pending_files = value["repo_changes"]["onboard_files"]
.as_array()
.map_or(0, Vec::len);
format!(
"一键接入 project_id={}:{}{}{}",
value["project_id"].as_str().unwrap_or("?"),
value["summary"].as_str().unwrap_or("结构化报告已返回"),
if pending_files > 0 {
format!(";待提交接入文件 {pending_files} 个(炼境不 add、不 commit)")
} else {
String::new()
},
if actions.is_empty() {
String::new()
} else {
format!(";next_actions:{}", actions.join("、"))
}
)
}
fn tool_success_response(tool_name: &str, text: String) -> Value { fn tool_success_response(tool_name: &str, text: String) -> Value {
const STRUCTURED_TOOLS: &[&str] = &[ const STRUCTURED_TOOLS: &[&str] = &[
"get_delivery_status", "get_delivery_status",
@ -874,6 +1103,8 @@ fn tool_success_response(tool_name: &str, text: String) -> Value {
"verify_onboarding", "verify_onboarding",
"probe_client_parity", "probe_client_parity",
"register_project", "register_project",
"plan_onboarding",
"onboard_project",
]; ];
if STRUCTURED_TOOLS.contains(&tool_name) { if STRUCTURED_TOOLS.contains(&tool_name) {
if let Ok(structured) = serde_json::from_str::<Value>(&text) { if let Ok(structured) = serde_json::from_str::<Value>(&text) {
@ -1540,6 +1771,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
crate::mcp_inject::probe_client_parity(pid, tier) crate::mcp_inject::probe_client_parity(pid, tier)
.and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string())) .and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string()))
} }
"plan_onboarding" => plan_onboarding_call(&args),
"onboard_project" => onboard_project_call(&args),
"verify_onboarding" => { "verify_onboarding" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let root = resolve_project_root(pid)?; let root = resolve_project_root(pid)?;
@ -2262,7 +2495,9 @@ fn render_all_projects(
name, id, inject_icon, group_str, path_str name, id, inject_icon, group_str, path_str
)); ));
} }
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。"); out.push_str(
"---\n对未注入项目:先 `plan_onboarding(project_id)` 预览,经用户确认后 `onboard_project` 一键接入(只需本机 MCP 注入时用 `inject_mcp(project_id)`)。",
);
Ok(out) Ok(out)
} }
@ -2395,7 +2630,7 @@ fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result<String
} }
out.push_str( out.push_str(
"---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再调用 `apply_onboarding_pack(project_id)` 分发接入包。", "---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再 `plan_onboarding(project_id)` 预览、经用户确认后 `onboard_project` 一键接入。",
); );
Ok(out) Ok(out)
} }
@ -2871,6 +3106,374 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
// ── 一键接入投影(agent-client-parity P8e)────────────────────────────────────
fn find_tool(catalog: &Value, name: &str) -> Value {
catalog["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == name)
.unwrap_or_else(|| panic!("tools/list 缺 {name}"))
.clone()
}
fn required_fields(tool: &Value) -> Vec<String> {
tool["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.map(|field| field.as_str().unwrap().to_string())
.collect()
}
/// P8e acceptance:全局与项目路由都有 plan_onboarding / onboard_project;onboard_project 的 required 含
/// expected_digest 与 confirmed;plan 为 readOnly、apply 为 idempotent(非破坏);两者共用两个可选选项。
#[test]
fn onboarding_tools_are_on_both_routes_with_confirmation_contract() {
let global = tools_list_result();
let plan = find_tool(&global, "plan_onboarding");
let apply = find_tool(&global, "onboard_project");
assert_eq!(required_fields(&plan), ["project_id"]);
let required = required_fields(&apply);
for field in ["project_id", "expected_digest", "confirmed"] {
assert!(required.iter().any(|value| value == field), "缺 {field}");
}
assert_eq!(
apply["inputSchema"]["properties"]["confirmed"]["type"],
"boolean"
);
assert_eq!(plan["annotations"]["readOnlyHint"], true);
assert_eq!(plan["annotations"]["idempotentHint"], true);
assert_eq!(plan["annotations"]["destructiveHint"], false);
assert_eq!(apply["annotations"]["readOnlyHint"], false);
assert_eq!(apply["annotations"]["idempotentHint"], true);
assert_eq!(apply["annotations"]["destructiveHint"], false);
assert_eq!(apply["annotations"]["openWorldHint"], false);
for tool in [&plan, &apply] {
let properties = &tool["inputSchema"]["properties"];
assert_eq!(properties["bypass_permissions"]["type"], "boolean");
assert_eq!(properties["skip_targets"]["type"], "array");
assert_eq!(properties["skip_targets"]["items"]["type"], "string");
for reason in ["manual_entry", "shared_key"] {
assert!(properties["skip_targets"]["description"]
.as_str()
.unwrap()
.contains(reason));
}
}
let plan_text = plan["description"].as_str().unwrap();
for phrase in [
"onboard_project",
"expected_digest",
"confirmed=true",
"给用户看",
"信任",
] {
assert!(plan_text.contains(phrase), "plan 描述应写明「{phrase}」");
}
let apply_text = apply["description"].as_str().unwrap();
for phrase in [
"plan_onboarding",
"用户明确确认",
"expected_digest",
"confirmed=true",
"在 Codex 里信任",
"公开仓库",
"private=true",
] {
assert!(
apply_text.contains(phrase),
"onboard_project 描述应写明「{phrase}」"
);
}
// 新接入路径排在旧的分步入口之前
let names: Vec<&str> = global["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
let position = |name: &str| names.iter().position(|n| *n == name).unwrap();
assert!(position("onboard_project") < position("apply_onboarding_pack"));
assert_eq!(position("plan_onboarding") + 1, position("onboard_project"));
// 项目路由:可见、project_id 绑定为本项目且可省略,确认契约不变
let scoped = tools_list_result_for_project("p1");
for name in ["plan_onboarding", "onboard_project"] {
let tool = find_tool(&scoped, name);
assert_eq!(
tool["inputSchema"]["properties"]["project_id"]["const"],
"p1"
);
assert!(!required_fields(&tool)
.iter()
.any(|field| field == "project_id"));
}
let scoped_apply = find_tool(&scoped, "onboard_project");
assert_eq!(
required_fields(&scoped_apply),
["expected_digest", "confirmed"]
);
}
/// 项目路由强制本项目:跨项目参数在任何 IO 之前返回 scope_violation。
#[tokio::test]
async fn onboarding_tools_on_project_route_reject_cross_project_before_io() {
for (name, arguments) in [
("plan_onboarding", json!({ "project_id": "p2" })),
(
"onboard_project",
json!({ "project_id": "p2", "expected_digest": "d", "confirmed": true }),
),
] {
let response = tools_call_for_project(
Some(&json!({ "name": name, "arguments": arguments })),
"p1",
)
.await;
assert_eq!(response["isError"], true, "{name}");
assert_eq!(
response["structuredContent"]["code"], "scope_violation",
"{name}"
);
assert!(response["content"][0]["text"]
.as_str()
.unwrap()
.contains("绑定 project_id=p1"));
}
}
/// 选项只做类型解析:缺省为默认选项,参数名与 schema 一一对应;类型不对在任何 IO 之前报错(全局与项目路由一致)。
#[test]
fn onboard_options_follow_schema_names_and_default() {
assert_eq!(
onboard_options(&json!({ "project_id": "p1" })).unwrap(),
crate::onboard::apply::OnboardOptions::default()
);
assert_eq!(
onboard_options(&json!({ "bypass_permissions": null, "skip_targets": null })).unwrap(),
crate::onboard::apply::OnboardOptions::default()
);
// 按 schema 声明的选项名构造入参:schema 改名而解析未跟上时这里会失败
let schema = plan_onboarding_schema();
let mut args = json!({ "project_id": "p1" });
for (name, property) in schema["inputSchema"]["properties"].as_object().unwrap() {
match property["type"].as_str() {
Some("boolean") => args[name] = json!(false),
Some("array") => args[name] = json!([".codex/config.toml", "claude"]),
_ => {}
}
}
let options = onboard_options(&args).unwrap();
assert!(!options.bypass_permissions);
assert_eq!(options.skip_targets, [".codex/config.toml", "claude"]);
let options_of = |schema: Value| {
let mut properties = schema["inputSchema"]["properties"]
.as_object()
.unwrap()
.clone();
for name in ["project_id", "expected_digest", "confirmed"] {
properties.remove(name);
}
properties
};
let plan_options = options_of(plan_onboarding_schema());
assert_eq!(plan_options.len(), 2);
assert_eq!(
plan_options,
options_of(onboard_project_schema()),
"两工具的选项参数必须一致"
);
for (args, expected) in [
(
json!({ "bypass_permissions": "no" }),
"bypass_permissions 必须为布尔值",
),
(
json!({ "skip_targets": "claude" }),
"skip_targets 必须为字符串数组",
),
(
json!({ "skip_targets": ["claude", 3] }),
"skip_targets 的每一项必须为字符串",
),
] {
assert!(
onboard_options(&args).unwrap_err().contains(expected),
"{args}"
);
}
}
#[tokio::test]
async fn onboarding_tools_reject_malformed_arguments_before_io() {
let cases = [
(
"plan_onboarding",
json!({ "project_id": "p1", "skip_targets": "claude" }),
"skip_targets",
),
(
"onboard_project",
json!({ "project_id": "p1", "expected_digest": "d", "confirmed": "true" }),
"confirmed 必须为布尔值",
),
(
"onboard_project",
json!({ "project_id": "p1", "expected_digest": 7, "confirmed": true }),
"expected_digest 必须为字符串",
),
(
"onboard_project",
json!({ "project_id": "p1", "expected_digest": "d", "confirmed": true, "bypass_permissions": 1 }),
"bypass_permissions",
),
];
for (name, arguments, expected) in cases {
let global = tools_call(Some(
&json!({ "name": name, "arguments": arguments.clone() }),
))
.await;
assert_eq!(global["isError"], true, "{name} {arguments}");
assert!(
global["content"][0]["text"]
.as_str()
.unwrap()
.contains(expected),
"{global}"
);
// 项目路由省略 project_id 时自动绑定本项目,同样先做类型解析
let mut scoped_args = arguments.clone();
scoped_args.as_object_mut().unwrap().remove("project_id");
let scoped = tools_call_for_project(
Some(&json!({ "name": name, "arguments": scoped_args })),
"p1",
)
.await;
assert_eq!(scoped["isError"], true, "{name}");
assert!(scoped["content"][0]["text"]
.as_str()
.unwrap()
.contains(expected));
}
}
/// structuredContent 是本体的原样结构(序列化真实类型,字段改名即红),摘要给出状态、digest 与下一步。
#[test]
fn onboarding_responses_carry_structured_content_and_one_line_summary() {
use crate::onboard::plan::{
BlockReason, BlockedItem, CodexTrust, FileAction, MachineStep, NextAction,
OnboardingPlan, PlannedFile,
};
let planned = |path: &str, action: FileAction| PlannedFile {
path: path.into(),
action,
diff: None,
warning: None,
content_sha256: "x".into(),
before_sha256: None,
};
let mut plan = OnboardingPlan {
project_id: "p1".into(),
root: "E:\\code\\app".into(),
files: vec![
planned("AGENTS.md", FileAction::Create),
planned(".codex/config.toml", FileAction::Create),
planned("lefthook.yml", FileAction::Noop),
],
machine_changes: vec![
MachineStep {
step: "token".into(),
status: "apply".into(),
detail: String::new(),
},
MachineStep {
step: "codex".into(),
status: "noop".into(),
detail: String::new(),
},
],
blocked: Vec::new(),
skipped: Vec::new(),
bypass_permissions: true,
codex_trust: CodexTrust::Untrusted,
next_actions: vec![NextAction {
action: "codex_trust".into(),
detail: "信任".into(),
}],
digest: "abc123".into(),
};
let response =
tool_success_response("plan_onboarding", serde_json::to_string(&plan).unwrap());
assert_eq!(response["structuredContent"]["digest"], "abc123");
let text = response["content"][0]["text"].as_str().unwrap();
for phrase in [
"project_id=p1",
"E:\\code\\app",
"create 2 / update 0 / noop 1",
"token=apply codex=noop",
"阻断 0 项",
"bypassPermissions 新写入=是",
"Codex 信任=untrusted",
"digest=abc123",
"confirmed=true",
] {
assert!(text.contains(phrase), "缺「{phrase}」:{text}");
}
plan.blocked = vec![BlockedItem {
target: ".codex/config.toml".into(),
reason: BlockReason::ManualEntry,
detail: "手写".into(),
}];
let blocked =
tool_success_response("plan_onboarding", serde_json::to_string(&plan).unwrap());
let text = blocked["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("阻断 1 项(.codex/config.toml(manual_entry))"),
"{text}"
);
assert!(text.contains("skip_targets"), "{text}");
let mut report = crate::onboard::report::OnboardReport::stopped(
"p1",
crate::onboard::report::OnboardStatus::Done,
"x",
std::time::Instant::now(),
);
report.summary = "done:9 步均已收敛".into();
report.next_actions = plan.next_actions.clone();
report.repo_changes = Some(crate::onboard::report::RepoChanges::Available {
status_porcelain: vec!["?? AGENTS.md".into()],
diff_stat: String::new(),
onboard_files: vec![crate::onboard::report::PendingFile {
path: "AGENTS.md".into(),
code: "??".into(),
}],
other_changes: 0,
});
let response =
tool_success_response("onboard_project", serde_json::to_string(&report).unwrap());
assert_eq!(response["structuredContent"]["status"], "done");
assert!(
response.get("isError").is_none(),
"status 在结构里,不当工具错误"
);
let text = response["content"][0]["text"].as_str().unwrap();
for phrase in [
"project_id=p1",
"done:9 步均已收敛",
"待提交接入文件 1 个",
"next_actions:codex_trust",
] {
assert!(text.contains(phrase), "缺「{phrase}」:{text}");
}
}
/// P8f 审查 M1:scan_unregistered_projects 与登记重复检查同一口径——已登记目录换了写法 /// P8f 审查 M1:scan_unregistered_projects 与登记重复检查同一口径——已登记目录换了写法
/// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。 /// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。
#[test] #[test]

View File

@ -407,6 +407,7 @@ pub fn onboard_project<S: CredentialStore>(
repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed), repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed),
written_files: applied.written, written_files: applied.written,
head: report::head_check(&resolved.root, head_before), head: report::head_check(&resolved.root, head_before),
repo_layout: report::repo_layout(&resolved.root),
started, started,
}) })
} }

View File

@ -7,8 +7,8 @@ use crate::credential_store::MemoryCredentialStore;
use crate::onboard::clients::{ClientOutcome, ProjectFacts}; use crate::onboard::clients::{ClientOutcome, ProjectFacts};
use crate::onboard::plan::{BlockReason, FileAction}; use crate::onboard::plan::{BlockReason, FileAction};
use crate::onboard::probe_l1::{ use crate::onboard::probe_l1::{
Cause, CellState, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Located, Cause, CellState, Check, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source,
McpHttp, ProcessOutput, StaticFinding, Located, McpHttp, ProcessOutput, StaticFinding,
}; };
use crate::onboard::report::RepoChanges; use crate::onboard::report::RepoChanges;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -201,16 +201,26 @@ impl OnboardHost for FakeHost {
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum Lefthook { enum Lefthook {
/// 模拟 lefthook install:写 .git/hooks/pre-commit 并退出 0 /// 模拟 lefthook install:写 git 的 hooks 目录下的 pre-commit 并退出 0
Installs, Installs,
/// 起不来(不存在) /// 起不来(不存在)
Missing, Missing,
} }
/// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。 /// codex mcp get 的模拟回答(只在 FakeLocator 定位到 Codex 时才会被调用)。
#[derive(Clone, Copy, PartialEq, Eq)]
enum CodexReply {
/// 目录未受信任:Codex 看不到项目 .codex/config.toml 里的 lian-jing(H-P8-1)
Untrusted,
/// 生效的是用户级全局地址(另一种未就绪原因)
GlobalRoute,
}
/// 子进程替身:模拟 lefthook install 与 Codex 自省(codex --version、codex mcp get),其余程序一律「无法启动」。
/// 每次 lefthook install 记录当时接入写锁是否空闲(P8d 审查 ⑦:lefthook 不占写锁)。 /// 每次 lefthook install 记录当时接入写锁是否空闲(P8d 审查 ⑦:lefthook 不占写锁)。
struct FakeRunner { struct FakeRunner {
lefthook: Mutex<Lefthook>, lefthook: Mutex<Lefthook>,
codex: Mutex<CodexReply>,
specs: Mutex<Vec<ProcessSpec>>, specs: Mutex<Vec<ProcessSpec>>,
write_lock: Arc<Mutex<()>>, write_lock: Arc<Mutex<()>>,
lock_free_during_install: Mutex<Vec<bool>>, lock_free_during_install: Mutex<Vec<bool>>,
@ -220,12 +230,40 @@ impl FakeRunner {
fn new(write_lock: Arc<Mutex<()>>) -> Self { fn new(write_lock: Arc<Mutex<()>>) -> Self {
Self { Self {
lefthook: Mutex::new(Lefthook::Installs), lefthook: Mutex::new(Lefthook::Installs),
codex: Mutex::new(CodexReply::Untrusted),
specs: Mutex::new(Vec::new()), specs: Mutex::new(Vec::new()),
write_lock, write_lock,
lock_free_during_install: Mutex::new(Vec::new()), lock_free_during_install: Mutex::new(Vec::new()),
} }
} }
fn codex(&self, spec: &ProcessSpec) -> ProcessOutput {
if spec.args.iter().any(|arg| arg == "--version") {
return ProcessOutput {
exit_code: Some(0),
stdout: "codex-cli 0.155.0\n".into(),
..ProcessOutput::default()
};
}
match *self.codex.lock().unwrap() {
CodexReply::Untrusted => ProcessOutput {
exit_code: Some(1),
stderr: "Error: No MCP server named 'lian-jing' found.\n".into(),
..ProcessOutput::default()
},
CodexReply::GlobalRoute => ProcessOutput {
exit_code: Some(0),
stdout: serde_json::json!({
"name": "lian-jing",
"enabled": true,
"transport": { "type": "streamable_http", "url": "http://127.0.0.1:27190/mcp" }
})
.to_string(),
..ProcessOutput::default()
},
}
}
fn lefthook_runs(&self) -> usize { fn lefthook_runs(&self) -> usize {
self.specs self.specs
.lock() .lock()
@ -239,8 +277,12 @@ impl FakeRunner {
impl ProcessRunner for FakeRunner { impl ProcessRunner for FakeRunner {
fn run(&self, spec: &ProcessSpec) -> Result<ProcessOutput, String> { fn run(&self, spec: &ProcessSpec) -> Result<ProcessOutput, String> {
self.specs.lock().unwrap().push(spec.clone()); self.specs.lock().unwrap().push(spec.clone());
if !spec.program.to_string_lossy().contains("lefthook") { let program = spec.program.to_string_lossy();
return Err("测试执行器只模拟 lefthook".into()); if program.contains("codex") {
return Ok(self.codex(spec));
}
if !program.contains("lefthook") {
return Err("测试执行器只模拟 lefthook 与 codex".into());
} }
self.lock_free_during_install self.lock_free_during_install
.lock() .lock()
@ -249,7 +291,15 @@ impl ProcessRunner for FakeRunner {
match *self.lefthook.lock().unwrap() { match *self.lefthook.lock().unwrap() {
Lefthook::Missing => Err("program not found".into()), Lefthook::Missing => Err("program not found".into()),
Lefthook::Installs => { Lefthook::Installs => {
let hooks = spec.cwd.as_ref().unwrap().join(".git").join("hooks"); // 与真实 lefthook 一样装到 git 的 hooks 目录(worktree 的 .git 是文件,hooks 在主仓库)
let cwd = spec.cwd.as_ref().unwrap();
let hooks = PathBuf::from(
git(
cwd,
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
)
.trim(),
);
std::fs::create_dir_all(&hooks).unwrap(); std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\n# lefthook 测试替身\n") std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\n# lefthook 测试替身\n")
.unwrap(); .unwrap();
@ -262,16 +312,22 @@ impl ProcessRunner for FakeRunner {
} }
} }
/// 本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。 /// 默认本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。
/// 记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。 /// codex_installed 时定位到 Codex(自省经 FakeRunner 模拟)。记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。
struct FakeLocator { struct FakeLocator {
located: Mutex<Vec<ClientId>>, located: Mutex<Vec<ClientId>>,
codex_installed: AtomicBool,
} }
impl ClientLocator for FakeLocator { impl ClientLocator for FakeLocator {
fn locate(&self, client: ClientId) -> Option<Located> { fn locate(&self, client: ClientId) -> Option<Located> {
self.located.lock().unwrap().push(client); self.located.lock().unwrap().push(client);
None (client == ClientId::Codex && self.codex_installed.load(Ordering::SeqCst)).then(|| {
Located {
program: Some(PathBuf::from("codex.exe")),
version: None,
}
})
} }
fn git_bash(&self) -> Option<PathBuf> { fn git_bash(&self) -> Option<PathBuf> {
@ -387,6 +443,7 @@ impl Harness {
runner: FakeRunner::new(lock.clone()), runner: FakeRunner::new(lock.clone()),
locator: FakeLocator { locator: FakeLocator {
located: Mutex::new(Vec::new()), located: Mutex::new(Vec::new()),
codex_installed: AtomicBool::new(false),
}, },
lock, lock,
root, root,
@ -665,6 +722,9 @@ fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() {
} }
// 改了配置:提示重开已打开的会话(P4a 审查 ③:不据 reload_required 推断已重载) // 改了配置:提示重开已打开的会话(P4a 审查 ③:不据 reload_required 推断已重载)
assert!(listed.contains(&"reopen_sessions")); assert!(listed.contains(&"reopen_sessions"));
// 项目根就是仓库根:没有「仓库布局」说明,也没有只等人操作的项
assert!(!listed.contains(&"repo_layout"), "{listed:?}");
assert!(report.completion.iter().all(|item| !item.pending_user));
// L1 三端都检查过(本机替身「未安装」,不计失败) // L1 三端都检查过(本机替身「未安装」,不计失败)
let l1 = report.l1.as_ref().unwrap(); let l1 = report.l1.as_ref().unwrap();
@ -1345,10 +1405,176 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() {
onboard_files.iter().any(|file| file.path == "AGENTS.md"), onboard_files.iter().any(|file| file.path == "AGENTS.md"),
"接入文件路径相对项目根(在项目根执行 git add):{onboard_files:?}" "接入文件路径相对项目根(在项目根执行 git add):{onboard_files:?}"
); );
// 统筹决定(P8d 审查 ⑩):到不了 done 的唯一原因是「lefthook hook 挂载」只认项目根 .git/hooks,
// next_action repo_layout 说明原因与外层仓库根
assert_layout_is_the_only_obstacle(&report, "子目录");
let layout = report
.next_actions
.iter()
.find(|action| action.action == "repo_layout")
.unwrap();
let outer_name = outer.file_name().unwrap().to_string_lossy().to_string();
assert!(layout.detail.contains(&outer_name), "{}", layout.detail);
drop(h); drop(h);
let _ = std::fs::remove_dir_all(&outer); let _ = std::fs::remove_dir_all(&outer);
} }
/// 仓库子目录 / worktree 项目:status=partial,未就绪的只有「lefthook hook 挂载」(步骤全部已收敛),
/// next_actions 有一条 repo_layout 说明为何到不了 done,摘要指向它。
fn assert_layout_is_the_only_obstacle(report: &OnboardReport, kind: &str) {
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
assert!(
report.steps.iter().all(|step| step.status.is_settled()),
"{:#?}",
report.steps
);
let not_ready: Vec<&str> = report
.completion
.iter()
.filter(|item| !item.ready && !item.pending_user)
.map(|item| item.item.as_str())
.collect();
assert_eq!(
not_ready,
["lefthook hook 挂载"],
"{:#?}",
report.completion
);
let layouts: Vec<&crate::onboard::plan::NextAction> = report
.next_actions
.iter()
.filter(|action| action.action == "repo_layout")
.collect();
assert_eq!(layouts.len(), 1, "{:?}", report.next_actions);
for phrase in [kind, "lefthook hook 挂载", "done"] {
assert!(
layouts[0].detail.contains(phrase),
"缺「{phrase}」:{}",
layouts[0].detail
);
}
assert!(report.summary.contains("repo_layout"), "{}", report.summary);
}
/// 项目根是 git worktree(.git 是文件):hook 装在主仓库的 hooks 目录,「lefthook hook 挂载」只认项目根
/// .git/hooks,到不了 done;next_action repo_layout 说明原因(统筹决定,P8d 审查 ⑩)。
#[test]
fn worktree_project_is_partial_and_next_action_explains_why() {
let main = temp_dir("wtmain", true);
let root = std::env::temp_dir().join(format!("lj_p8d_wt_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
git(
&main,
&[
"worktree",
"add",
"-q",
"--detach",
&root.display().to_string(),
],
);
assert!(root.join(".git").is_file());
let conn = crate::db::conn_with_schema();
register(&conn, "p8d-wt", &root.display().to_string());
let h = Harness::with(conn, root, "p8d-wt".into());
let plan = h.plan(OnboardOptions::default());
let report = h.apply(&plan.digest, true, OnboardOptions::default());
assert_eq!(step(&report, "git_hooks").status, StepStatus::Applied);
assert!(
main.join(".git/hooks/pre-commit").exists(),
"hook 装在主仓库"
);
assert_layout_is_the_only_obstacle(&report, "worktree");
// 再跑一次照样到不了 done(判定口径没变),git_hooks 每次都会重跑——与 next_action 的说明一致
let again = h.plan(OnboardOptions::default());
let second = h.apply(&again.digest, true, OnboardOptions::default());
assert_layout_is_the_only_obstacle(&second, "worktree");
assert_eq!(h.runner.lefthook_runs(), 2);
drop(h);
let _ = std::fs::remove_dir_all(&main);
}
/// 统筹决定(P8d 审查 ⑩):Codex 已安装但未信任该目录——L1 的 Codex 列 C–F 记 codex_project_untrusted,这几格只等
/// 用户在 Codex 里信任目录(炼境不写信任表,设计 Q5),不计入 done:status=done,completion 单列 pending_user
/// 「Codex 信任」,next_actions 保留一条 codex_trust。其余原因的 Codex 格照常计入(生效的是全局地址 → partial)。
#[test]
fn codex_untrusted_cells_are_pending_user_and_do_not_block_done() {
let h = Harness::new("codextrust", true);
*h.host.trust.lock().unwrap() = CodexTrust::Untrusted;
h.locator.codex_installed.store(true, Ordering::SeqCst);
let plan = h.plan(OnboardOptions::default());
let report = h.apply(&plan.digest, true, OnboardOptions::default());
let l1 = report.l1.as_ref().unwrap();
let codex = l1
.clients
.iter()
.find(|column| column.client == ClientId::Codex)
.unwrap();
for check in [Check::C, Check::D, Check::E, Check::F] {
assert_eq!(
codex.cells[&check].cause,
Some(Cause::CodexProjectUntrusted),
"{check:?}: {:?}",
codex.cells[&check]
);
}
assert!(!l1.all_applicable_ready, "L1 自身的原始判定不改写");
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
let pending: Vec<_> = report
.completion
.iter()
.filter(|item| item.pending_user)
.collect();
assert_eq!(pending.len(), 1, "{:#?}", report.completion);
assert_eq!(pending[0].item, "Codex 信任");
assert!(!pending[0].ready, "如实记未就绪,只是不计入 done");
assert!(pending[0].detail.contains("4 格"), "{}", pending[0].detail);
assert!(report
.completion
.iter()
.any(|item| item.item == "L1 适用格" && item.ready));
assert_eq!(
actions(&report)
.iter()
.filter(|action| **action == "codex_trust")
.count(),
1,
"计划已列 codex_trust,不重复:{:?}",
report.next_actions
);
assert!(
report.summary.contains("pending_user"),
"{}",
report.summary
);
// 计划预判为已信任(静态预判与 Codex 自省不一致)时,L1 的结论仍补一条 codex_trust
*h.host.trust.lock().unwrap() = CodexTrust::Trusted;
let trusted_plan = h.plan(OnboardOptions::default());
assert!(trusted_plan.next_actions.is_empty());
let rerun = h.apply(&trusted_plan.digest, true, OnboardOptions::default());
assert_eq!(rerun.status, OnboardStatus::Done, "{rerun:#?}");
assert_eq!(
actions(&rerun)
.iter()
.filter(|a| **a == "codex_trust")
.count(),
1
);
// 其余原因的 Codex 格照常计入:生效的是全局地址(route_global)→ partial,没有 pending_user 项
*h.runner.codex.lock().unwrap() = CodexReply::GlobalRoute;
let again = h.plan(OnboardOptions::default());
let global = h.apply(&again.digest, true, OnboardOptions::default());
assert_eq!(global.status, OnboardStatus::Partial, "{global:#?}");
assert!(global.completion.iter().all(|item| !item.pending_user));
assert!(global
.completion
.iter()
.any(|item| item.item == "L1 适用格" && !item.ready));
}
// ── P8d 审查处置的回归 ───────────────────────────────────────────────────────── // ── P8d 审查处置的回归 ─────────────────────────────────────────────────────────
/// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。 /// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。

View File

@ -5,10 +5,15 @@
//! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪, //! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪,
//! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然 //! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然
//! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。 //! 不通过的检查进入 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::clients::{ClientId, ClientOutcome};
use super::plan::{BlockedItem, NextAction, OnboardingPlan}; use super::plan::{BlockedItem, NextAction, OnboardingPlan};
use super::probe_l1::{Cell, L1Report}; use super::probe_l1::{Cause, Cell, L1Report};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
use std::time::Instant; use std::time::Instant;
@ -130,6 +135,35 @@ pub struct CompletionItem {
pub source: String, pub source: String,
pub ready: bool, pub ready: bool,
pub detail: String, pub detail: String,
/// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false,
/// 但不计入 done 判定;对应的 next_action 保留
#[serde(default)]
pub pending_user: bool,
}
impl CompletionItem {
fn new(item: impl Into<String>, source: &str, ready: bool, detail: impl Into<String>) -> 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。 /// 项目文件的待提交改动:炼境只报告、不 add、不 commit。
@ -320,6 +354,7 @@ pub(crate) struct AppliedFacts {
pub repo_changes: RepoChanges, pub repo_changes: RepoChanges,
pub written_files: Vec<String>, pub written_files: Vec<String>,
pub head: Option<HeadCheck>, pub head: Option<HeadCheck>,
pub repo_layout: RepoLayout,
pub started: Instant, pub started: Instant,
} }
@ -339,38 +374,65 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
}); });
} }
} }
None => completion.push(CompletionItem { None => completion.push(CompletionItem::new(
item: name.clone(), name.clone(),
source: "l0".into(), "l0",
ready: *pass, *pass,
detail: detail.clone(), detail.clone(),
}), )),
} }
} }
for (client, outcome) in &facts.clients_after { for (client, outcome) in &facts.clients_after {
completion.push(CompletionItem { completion.push(CompletionItem::new(
item: client_label(*client).into(), client_label(*client),
source: "clients".into(), "clients",
ready: outcome.is_settled(), outcome.is_settled(),
detail: outcome.describe(), 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(CompletionItem {
item: "L1 适用格".into(),
source: "l1".into(),
ready: facts.l1.all_applicable_ready,
detail: l1_summary(&facts.l1),
});
completion.push(match &facts.repo_changes { completion.push(match &facts.repo_changes {
RepoChanges::Available { RepoChanges::Available {
onboard_files, onboard_files,
other_changes, other_changes,
.. ..
} => CompletionItem { } => CompletionItem::new(
item: "项目文件改动可报告".into(), "项目文件改动可报告",
source: "repo".into(), "repo",
ready: true, true,
detail: format!( format!(
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}", "{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
onboard_files.len(), onboard_files.len(),
if *other_changes > 0 { if *other_changes > 0 {
@ -379,25 +441,22 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
String::new() String::new()
} }
), ),
}, ),
RepoChanges::Unavailable { reason } => CompletionItem { RepoChanges::Unavailable { reason } => {
item: "项目文件改动可报告".into(), CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone())
source: "repo".into(), }
ready: false,
detail: reason.clone(),
},
}); });
if let Some(head) = &facts.head { if let Some(head) = &facts.head {
completion.push(CompletionItem { completion.push(CompletionItem::new(
item: "HEAD 未变".into(), "HEAD 未变",
source: "repo".into(), "repo",
ready: head.unchanged, head.unchanged,
detail: if head.unchanged { if head.unchanged {
"写入前后 HEAD 相同(炼境未提交)".into() "写入前后 HEAD 相同(炼境未提交)"
} else { } else {
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into() "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属"
}, },
}); ));
} }
let step = |name: &str| facts.steps.iter().find(|s| s.step == name); let step = |name: &str| facts.steps.iter().find(|s| s.step == name);
@ -410,6 +469,9 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
}); });
} }
} }
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") { if applied("dsh") {
next_actions.push(NextAction { next_actions.push(NextAction {
action: "restart_dsh_gui".into(), action: "restart_dsh_gui".into(),
@ -471,9 +533,10 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
.filter(|s| !s.status.is_settled()) .filter(|s| !s.status.is_settled())
.map(|s| s.step.clone()) .map(|s| s.step.clone())
.collect(); .collect();
// pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done
let not_ready: Vec<String> = completion let not_ready: Vec<String> = completion
.iter() .iter()
.filter(|item| !item.ready) .filter(|item| !item.ready && !item.pending_user)
.map(|item| item.item.clone()) .map(|item| item.item.clone())
.collect(); .collect();
let status = if unsettled.is_empty() && not_ready.is_empty() { let status = if unsettled.is_empty() && not_ready.is_empty() {
@ -482,9 +545,16 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
OnboardStatus::Partial OnboardStatus::Partial
}; };
let changed = commit_message.is_some(); 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 { let summary = match status {
OnboardStatus::Done => format!( OnboardStatus::Done => format!(
"done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)", "done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)",
facts.steps.len(), facts.steps.len(),
if changed { if changed {
"接入文件有待提交改动(炼境不提交,只 add onboard_files);" "接入文件有待提交改动(炼境不提交,只 add onboard_files);"
@ -494,14 +564,11 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
next_actions.len() next_actions.len()
), ),
_ => format!( _ => format!(
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}", "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}",
unsettled.join("、"), unsettled.join("、"),
not_ready.join("、"), not_ready.join("、"),
if next_actions if layout_blocks_done {
.iter() ";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout"
.any(|action| action.action == "codex_trust")
{
"(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)"
} else { } else {
"" ""
} }
@ -537,7 +604,7 @@ fn client_label(client: ClientId) -> &'static str {
} }
} }
fn l1_summary(report: &L1Report) -> String { fn l1_summary(report: &L1Report, awaiting_trust: usize) -> String {
let cells: Vec<&Cell> = report let cells: Vec<&Cell> = report
.clients .clients
.iter() .iter()
@ -545,14 +612,72 @@ fn l1_summary(report: &L1Report) -> String {
.collect(); .collect();
let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count(); let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count();
format!( format!(
"{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms", "{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms{}",
report.clients.len(), report.clients.len(),
cells.len(), cells.len(),
bad, bad,
report.duration_ms 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<NextAction> {
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,用户原有改动不计): /// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
/// 有新文件记「接入」,只有修改记「收敛」。 /// 有新文件记「接入」,只有修改记「收敛」。
fn suggested_commit_message(changes: &RepoChanges) -> Option<String> { fn suggested_commit_message(changes: &RepoChanges) -> Option<String> {
@ -630,6 +755,31 @@ 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_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<String, String>| {
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)。 /// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。
pub(crate) fn git_head(root: &Path) -> Option<String> { pub(crate) fn git_head(root: &Path) -> Option<String> {
git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"]) git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"])
@ -945,4 +1095,397 @@ mod tests {
assert_eq!(level, "warn"); assert_eq!(level, "warn");
assert!(message.contains("claude"), "{message}"); 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<Cause>) -> 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<ClientColumn>) -> 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<NextAction>,
) -> 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);
}
} }

View File

@ -1730,6 +1730,169 @@ export interface ClientParityL1Report {
export const probeClientParity = (projectId: string, tier: "l1" = "l1") => export const probeClientParity = (projectId: string, tier: "l1" = "l1") =>
invoke<ClientParityL1Report>("probe_client_parity", { projectId, tier }); invoke<ClientParityL1Report>("probe_client_parity", { projectId, tier });
// ── 一键接入(agent-client-parity P8c 计划 / P8d 编排 / P8e 投影)────────────────────
// 类型与 src-tauri/src/onboard/plan.rs 的 OnboardingPlan、onboard/apply.rs 的 OnboardOptions、
// onboard/report.rs 的 OnboardReport 对应;MCP plan_onboarding / onboard_project 返回同一结构。
// 流程:planOnboarding → 给用户看计划 → 用户确认 → onboardProject(plan.digest, true, 同一组 options)。
/** 计划与应用共用的选项:两次必须一致,否则 digest 不符被拒 */
export interface OnboardOptions {
/** 默认 true:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions;false 即按项目取消(写显式 default) */
bypass_permissions?: boolean;
/** 用户逐项选择「跳过」的阻断项 target(plan.blocked[].target);只有 manual_entry / shared_key 可跳过 */
skip_targets?: string[];
}
export type OnboardFileAction = "create" | "update" | "noop";
export interface OnboardPlannedFile {
/** 相对项目根,统一用 / */
path: string;
action: OnboardFileAction;
/** update 的有上限行级差异;create 的新文件概要 */
diff?: string;
/** 混合文件预警(同时记入 blocked) */
warning?: string;
content_sha256: string;
/** update 时写入前内容的 sha256(进入 digest) */
before_sha256?: string;
}
/** 只有 manual_entry 与 shared_key 可在确认时跳过;其余只能处理后重新计划 */
export type OnboardBlockReason =
| "mixed_file"
| "identity_conflict"
| "manual_entry"
| "shared_key"
| "needs_human";
export interface OnboardBlockedItem {
target: string;
reason: OnboardBlockReason;
detail: string;
}
/** 本机七步:token、shim、claude、codex、dsh、product_group_buff、git_hooks */
export interface OnboardMachineStep {
step: string;
/** noop | apply | not_installed | not_applicable | unavailable | manual_kept | other_project | needs_human */
status: string;
detail: string;
}
export type CodexTrust = "trusted" | "untrusted" | "not_installed" | "unknown";
/** 机器只列出、由人完成的事项(不阻断):codex_trust、gitea_repo(默认建公开仓库)、ci、commit、repo_layout 等 */
export interface OnboardNextAction {
action: string;
detail: string;
}
export interface OnboardingPlan {
project_id: string;
/** 将要写入的项目根目录(进入 digest) */
root: string;
files: OnboardPlannedFile[];
machine_changes: OnboardMachineStep[];
/** 非空时 onboardProject 一律 refused */
blocked: OnboardBlockedItem[];
/** 按 skip_targets 跳过的阻断项(进入 digest) */
skipped?: OnboardBlockedItem[];
/** 本次会新写入 permissions.defaultMode=bypassPermissions */
bypass_permissions: boolean;
codex_trust: CodexTrust;
next_actions: OnboardNextAction[];
/** onboardProject 须带回同一 digest */
digest: string;
}
export type OnboardStatus = "done" | "partial" | "failed" | "refused" | "busy";
/** identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh */
export type OnboardStepReport = { step: string; detail: string } & (
| { status: "converged" | "applied" }
| { status: "skipped" | "not_applicable" | "failed" | "needs_human"; reason: string }
| { status: "blocked"; dep: string }
);
export interface OnboardCompletionItem {
item: string;
source: "l0" | "clients" | "l1" | "repo";
ready: boolean;
detail: string;
/** 只等用户在客户端里操作(目前只有「Codex 信任」):ready=false 但不计入 done */
pending_user: boolean;
}
export interface OnboardPendingFile {
/** 相对项目根 */
path: string;
/** porcelain 两位状态码(?? 未跟踪、" M" 已修改等) */
code: string;
}
export type OnboardRepoChanges =
| {
state: "available";
status_porcelain: string[];
diff_stat: string;
/** 待提交的接入文件:提交时只 add 这些 */
onboard_files: OnboardPendingFile[];
/** 不属于接入的改动条数:勿混进接入提交 */
other_changes: number;
}
| { state: "unavailable"; reason: string };
export interface OnboardHeadCheck {
before: string | null;
after: string | null;
unchanged: boolean;
}
export interface OnboardReport {
project_id: string;
status: OnboardStatus;
summary: string;
/** failed / refused / busy 的原因 */
reason?: string;
digest?: string;
/** refused(digest 不符、根目录或连接参数变了、有阻断项)时附当前计划,须重新给用户确认 */
plan?: OnboardingPlan;
blocked?: OnboardBlockedItem[];
bypass_permissions: boolean;
steps: OnboardStepReport[];
completion: OnboardCompletionItem[];
/** 客户端步骤未完成的一端整列 blocked(cause=dependency) */
l1?: ClientParityL1Report;
/** 最近一次 L3 结果说明(L3 尚未提供) */
l3: string;
written_files: string[];
repo_changes?: OnboardRepoChanges;
/** 非 git 仓库为空 */
head?: OnboardHeadCheck;
suggested_commit_message?: string;
next_actions: OnboardNextAction[];
duration_ms: number;
}
/** 只读:一键接入写入前计划(不写文件、不写 DB、不起客户端 CLI) */
export const planOnboarding = (projectId: string, options?: OnboardOptions) =>
invoke<OnboardingPlan>("plan_onboarding", { projectId, options });
/** 一键接入:须带回用户确认过的计划 digest 与 confirmed=true、同一组 options;首次可能 30 秒以上。结果看 status,refused 等不抛错 */
export const onboardProject = (
projectId: string,
expectedDigest: string,
confirmed: boolean,
options?: OnboardOptions,
) =>
invoke<OnboardReport>("onboard_project", {
projectId,
expectedDigest,
confirmed,
options,
});
// ── Agent Infrastructure ────────────────────────────────────────────────────── // ── Agent Infrastructure ──────────────────────────────────────────────────────
export interface DetectedContract { export interface DetectedContract {