Compare commits

..

No commits in common. "d7f481616a902715b540a449b10ecd3c083c5bdc" and "b7ad0dc8b926d4ac0e054c85e820c7ac7b5fc3de" have entirely different histories.

12 changed files with 75 additions and 1655 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、不直接取连接池,适应度测试机器校验;生产依赖由 mcp_inject 组装,MCP 工具与 Tauri command plan_onboarding / onboard_project 只一行委托) src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
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,7 +137,6 @@ 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。
@ -187,7 +186,6 @@ 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,44 +488,6 @@ 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,8 +443,6 @@ 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,8 +1701,7 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ──────────────────────────────────── // ── 一键接入(P8c 计划 / P8d 编排):生产入口 ────────────────────────────────────
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。 // 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
// 对外投影(P8e):MCP 工具 plan_onboarding / onboard_project(mcp_tools)与同名 Tauri command // 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
// (commands::onboarding)都只在这两个入口上做一行委托,不在投影里复制任何判定。
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 /// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
/// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源): /// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源):
@ -1796,6 +1795,8 @@ 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,
@ -1807,6 +1808,7 @@ 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,
@ -1857,6 +1859,7 @@ 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 {
@ -1879,6 +1882,7 @@ 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"])
@ -1892,6 +1896,7 @@ 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 注入状态。用于发现尚未注入的项目,再用 plan_onboarding 预览、经用户确认后 onboard_project 一键接入(inject_mcp 只做本机 MCP 注入)。", "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
"inputSchema": { "type": "object", "properties": {}, "required": [] } "inputSchema": { "type": "object", "properties": {}, "required": [] }
}, },
{ {
@ -410,131 +410,11 @@ 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 {
@ -774,7 +654,6 @@ 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"
@ -917,8 +796,6 @@ 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",
@ -989,112 +866,6 @@ 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",
@ -1103,8 +874,6 @@ 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) {
@ -1771,8 +1540,6 @@ 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)?;
@ -2495,9 +2262,7 @@ fn render_all_projects(
name, id, inject_icon, group_str, path_str name, id, inject_icon, group_str, path_str
)); ));
} }
out.push_str( out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
"---\n对未注入项目:先 `plan_onboarding(project_id)` 预览,经用户确认后 `onboard_project` 一键接入(只需本机 MCP 注入时用 `inject_mcp(project_id)`)。",
);
Ok(out) Ok(out)
} }
@ -2630,7 +2395,7 @@ fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result<String
} }
out.push_str( out.push_str(
"---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再 `plan_onboarding(project_id)` 预览、经用户确认后 `onboard_project` 一键接入。", "---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再调用 `apply_onboarding_pack(project_id)` 分发接入包。",
); );
Ok(out) Ok(out)
} }
@ -3106,374 +2871,6 @@ 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,7 +407,6 @@ 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, Check, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Cause, CellState, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Located,
Located, McpHttp, ProcessOutput, StaticFinding, McpHttp, ProcessOutput, StaticFinding,
}; };
use crate::onboard::report::RepoChanges; use crate::onboard::report::RepoChanges;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -201,26 +201,16 @@ 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,
} }
/// codex mcp get 的模拟回答(只在 FakeLocator 定位到 Codex 时才会被调用)。 /// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。
#[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>>,
@ -230,40 +220,12 @@ 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()
@ -277,12 +239,8 @@ 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());
let program = spec.program.to_string_lossy(); if !spec.program.to_string_lossy().contains("lefthook") {
if program.contains("codex") { return Err("测试执行器只模拟 lefthook".into());
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()
@ -291,15 +249,7 @@ 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 => {
// 与真实 lefthook 一样装到 git 的 hooks 目录(worktree 的 .git 是文件,hooks 在主仓库) let hooks = spec.cwd.as_ref().unwrap().join(".git").join("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();
@ -312,22 +262,16 @@ impl ProcessRunner for FakeRunner {
} }
} }
/// 默认本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。 /// 本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。
/// codex_installed 时定位到 Codex(自省经 FakeRunner 模拟)。记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。 /// 记录被定位的客户端,用来证明 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);
(client == ClientId::Codex && self.codex_installed.load(Ordering::SeqCst)).then(|| { None
Located {
program: Some(PathBuf::from("codex.exe")),
version: None,
}
})
} }
fn git_bash(&self) -> Option<PathBuf> { fn git_bash(&self) -> Option<PathBuf> {
@ -443,7 +387,6 @@ 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,
@ -722,9 +665,6 @@ 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();
@ -1405,176 +1345,10 @@ 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,15 +5,10 @@
//! 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::{Cause, Cell, L1Report}; use super::probe_l1::{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;
@ -135,35 +130,6 @@ 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。
@ -354,7 +320,6 @@ 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,
} }
@ -374,65 +339,38 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
}); });
} }
} }
None => completion.push(CompletionItem::new( None => completion.push(CompletionItem {
name.clone(), item: name.clone(),
"l0", source: "l0".into(),
*pass, ready: *pass,
detail.clone(), detail: detail.clone(),
)), }),
} }
} }
for (client, outcome) in &facts.clients_after { 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 { completion.push(CompletionItem {
pending_user: true, item: client_label(*client).into(),
..CompletionItem::new( source: "clients".into(),
"Codex 信任", ready: outcome.is_settled(),
"l1", detail: outcome.describe(),
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::new( } => CompletionItem {
"项目文件改动可报告", item: "项目文件改动可报告".into(),
"repo", source: "repo".into(),
true, ready: true,
format!( detail: format!(
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}", "{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
onboard_files.len(), onboard_files.len(),
if *other_changes > 0 { if *other_changes > 0 {
@ -441,22 +379,25 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
String::new() String::new()
} }
), ),
), },
RepoChanges::Unavailable { reason } => { RepoChanges::Unavailable { reason } => CompletionItem {
CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone()) item: "项目文件改动可报告".into(),
} source: "repo".into(),
ready: false,
detail: reason.clone(),
},
}); });
if let Some(head) = &facts.head { if let Some(head) = &facts.head {
completion.push(CompletionItem::new( completion.push(CompletionItem {
"HEAD 未变", item: "HEAD 未变".into(),
"repo", source: "repo".into(),
head.unchanged, ready: head.unchanged,
if head.unchanged { detail: if head.unchanged {
"写入前后 HEAD 相同(炼境未提交)" "写入前后 HEAD 相同(炼境未提交)".into()
} else { } else {
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属" "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into()
}, },
)); });
} }
let step = |name: &str| facts.steps.iter().find(|s| s.step == name); let step = |name: &str| facts.steps.iter().find(|s| s.step == name);
@ -469,9 +410,6 @@ 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(),
@ -533,10 +471,9 @@ 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 && !item.pending_user) .filter(|item| !item.ready)
.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() {
@ -545,16 +482,9 @@ 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:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)", "done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)",
facts.steps.len(), facts.steps.len(),
if changed { if changed {
"接入文件有待提交改动(炼境不提交,只 add onboard_files);" "接入文件有待提交改动(炼境不提交,只 add onboard_files);"
@ -564,11 +494,14 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
next_actions.len() next_actions.len()
), ),
_ => format!( _ => format!(
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}", "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}",
unsettled.join("、"), unsettled.join("、"),
not_ready.join("、"), not_ready.join("、"),
if layout_blocks_done { if next_actions
";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout" .iter()
.any(|action| action.action == "codex_trust")
{
"(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)"
} else { } else {
"" ""
} }
@ -604,7 +537,7 @@ fn client_label(client: ClientId) -> &'static str {
} }
} }
fn l1_summary(report: &L1Report, awaiting_trust: usize) -> String { fn l1_summary(report: &L1Report) -> String {
let cells: Vec<&Cell> = report let cells: Vec<&Cell> = report
.clients .clients
.iter() .iter()
@ -612,72 +545,14 @@ fn l1_summary(report: &L1Report, awaiting_trust: usize) -> 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> {
@ -755,31 +630,6 @@ 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"])
@ -1095,397 +945,4 @@ 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,169 +1730,6 @@ 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 {