diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 7fc86f4..58054e4 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -295,15 +295,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - review: "2026-10-03 独立只读审查 6c69aa2,结论 fix_then_merge,10 条发现处置:① [中] 锁外解析、digest 不绑根目录 → 已修:写锁内重新解析登记(登记消失 failed;根目录或 McpTarget(端口、地址、helper)与锁外不一致 refused 并附当前计划);OnboardingPlan 新增 root(将写入的目录)并进 digest;SystemOnboardHost 每次 target() 重读端口;delete_project 改为持 MCP_OP_LOCK 依次执行 remove_project_mcp_locked 与删登记的数据库部分(Gitea 网络清理在锁外),关掉「清理释放锁后、删登记前」的窗口;reinject_all_mcp 的目标快照改在写锁内取(正在进行的一键接入结束后才取,避免锁内用旧端口、快照时还没 token 的项目停在旧端口)。补 registration_changes_while_waiting_for_write_lock_are_caught_inside_the_lock(文件 DB 两条连接:apply 停在写锁前时删登记 / 迁根目录 / 改端口 → failed / refused / refused,两个目录零写入、无 token、无组)、digest_confirmed_for_one_root_is_refused_after_root_moves(同名目录迁到另一父目录、文件计划一致仍拒绝)、plan_shows_root_and_binds_it_into_digest;G8 用例 deleting_project_registration_removes_its_webhook_secret 加断言:清理闭包在写锁内、先于删登记执行,结束后释放锁。② [中] 阻断项无跳过入口 → 已修(按设计 §4 C1 实现,未登记为延后):OnboardOptions 新增 skip_targets,只有 manual_entry 与 shared_key 可跳过(BlockReason::skippable),移入计划新增的 skipped 并进 digest;apply 时被跳过的文件级问题不算 project_files 失败(detail 注明保留现状),对应一端记 skipped(manual_kept|other_project)、不调用适配器;identity_conflict、mixed_file、needs_human 列进来照旧阻断,refused 文案提示 skip_targets。OnboardOptions 因含 Vec 不再 Copy,plan_project / onboard_project 改收 &OnboardOptions。补 manual_codex_entry_can_be_skipped_but_identity_conflict_cannot(默认 refused;跳过后 codex skipped、适配器 0 次、手写文件字节不变、project_files applied;手写条目未指向本项目地址,「Codex MCP 已配置」如实未就绪 → partial;跳过与未跳过的 digest 互不通用;身份冲突列进 skip_targets 仍 refused)、identity_conflict_and_needs_human_cannot_be_skipped,并扩充 manual_codex_entry_and_machine_manual_states_block。P8g 接口形态:plan(skip_targets) 展示 blocked(可跳过的给「跳过」)与 skipped → apply 带同一 skip_targets 与 digest。③ [低] 提交建议混入用户改动 → 已修:报告新增 written_files;TrackingSink 另记写入序列管理的文件;repo_changes 用 pathspec「-- .」限定项目根,新增 onboard_files[{path(相对项目根), code}] 与 other_changes;建议提交信息只看 onboard_files,next_actions 新增 commit(项目根 git add -- ,有其他改动时提示勿 git add -A)。补 user_changes_are_kept_out_of_the_onboarding_commit(未跟踪 notes.md 与改了一半的 README.md 计入 other_changes=2、不进 commit 提示);子目录用例补外层仓库未提交文件不出现、onboard_files 相对项目根;report 单测补 porcelain -z 解析与「managed 但本次未写的已跟踪改动不算接入文件」。④ [低] 一键接入不写 mcp_inject 事件 → 已修:onboard::report::inject_event 纯函数(未写入不记;token、shim、三端步骤全部完成记 info,否则 warn 列出未完成步骤),mcp_inject::onboard_project 在 onboard 日志后补写 category=mcp_inject(context 带 project_id)。补 inject_event_follows_injection_steps_only;生产写日志一行未单测。⑤ [低] codex 步「预计 apply → 实际 converged」与 blocked 文案失实 → 已修:ClientAdapter 新增默认方法 project_config_file()(CodexAdapter 返回 .codex/config.toml);本次 project_files 写过该文件且适配器复核 converged 时记 applied(detail 注明由 project_files 写入、本步复核);token / shim 门控未过时 codex 的 blocked 说明如实写「文件已写入、helper 引用的稳定入口或 token 未就绪,修复后重跑生效」。测试的 Codex 替身改为读写真实 .codex/config.toml,① 断言计划 apply → 报告 applied 且 detail 提 project_files,shim 用例断言 codex 文案。⑥ [低] lefthook 启动错误被吞 → 已修:保留各候选启动错误;本地可执行文件存在却起不来记 failed 并附错误(不误报「未找到」、不回退 PATH);PATH 候选都起不来记 missing_binary 并附错误。lefthook_install 用例补两条断言。⑦ [低] lefthook 与建组 / buff 占写锁 → 部分修:git_hooks 移到释放写锁后执行,① 用例经执行器替身断言 install 时写锁空闲;product_group_buff 有意留在锁内(只写 DB、毫秒级,与删除登记串行,避免给已删项目建组或登记 buff)。⑧ [信息] report.rs 的 git 观测绕过 ProcessRunner → 未改代码:计划的混合文件检查、Codex 信任预判同样直接起 git,只改 report.rs 会成半迁移;登记 known_limits ①,apply.rs 模块文档如实改写边界。⑨ [信息] P8e 投影的 async 写法 → 采纳为 P8e 指引(P8e 卡说明已补);project-context.yaml 把 apply_onboarding_pack「在线程池执行」的不实表述改为「在异步运行时任务里执行、占一个 worker」,代码未改(审查认可与 buff.rs、groups.rs 一致)。⑩ [信息] 非 git、仓库子目录、worktree、Codex 未信任到不了 done → 未改,转统筹决定(codex_project_untrusted 是否视为 next_action 不计入 done;非 git / 子目录是否补 git_init / 由外层仓库接入的 next_action);known_limits ④⑤ 已登记。修复后全量 cargo test 402 passed / 0 failed / 6 ignored(较 394 新增 8 条),编译 0 warning;pnpm typecheck 通过。改断言报备:report 单测 head_check 中「只改 a.txt 即给收敛提交信息」改为「a.txt 不是接入文件 → 无接入提交信息、计入其他改动;列入本次写入才给收敛信息」——提交建议语义按审查 ③ 有意收窄;① 用例的建议提交信息断言由前缀 chore(lian-jing) 收紧为「一键接入」,lefthook 缺失断言加启动错误文本(均为加强);① 用例「九步全 applied」未改,codex 步在新语义下仍为 applied(原先靠替身不写文件成立,现由真实文件耦合成立)。变异验证(git add 暂存后用 Edit 注入、跑 cargo test onboard:: commands::projects、git checkout 从暂存区还原,未用 sed):第一轮 5 处——去掉锁内根目录 / 端口比对、git_hooks 移回锁内、digest 去掉 root、接入文件判定放宽、删登记的清理移到锁外——恰好 7 条预期用例失败、79 条通过;第二轮 5 处——codex 两处 project_files 耦合(blocked 文案、applied 映射)、被跳过的文件级问题算失败、本地 lefthook 起不来当缺失、partial 不补写注入事件——恰好 6 条失败、80 条通过;第三轮身份冲突与 needs_human 可跳过 → 2 条失败;第四轮被跳过的一端仍调用适配器 → 1 条失败。" - 待办:开 PR(M 级,分支 feat/agent-client-parity-p8d);P8e 合并、运行中的炼境重编译后由统筹做端到端实测,再标 ✅。 - merge_verify: "2026-10-03 统筹会话:PR #39(2f2a9f1,CI passed;独立审查 10 条:6 修、1 部分修、2 登记/转指引、1 转统筹)合并为 d018d44,合并结果全量 cargo test 402 passed / 0 failed / 6 ignored。acceptance ①–⑨ 均为 cargo onboard::apply 层,据此标 ✅;端到端(新登记项目 plan → onboard_project)随 P8e 合并后由统筹实测。统筹决定(审查 ⑩):codex_project_untrusted 按设计 Q5 计为 next_action、不计入 done(只放宽这一种原因),非 git 目录维持不能 done(acceptance ⑥),子目录与 worktree 补 next_action 说明——由 P8e 实现。" +- 统筹决定落地(审查 ⑩,2026-10-03,P8e 实现,详见 P8e 卡):codex_project_untrusted 视为 next_action、不阻断 done——L1 的 Codex 列里仅因 cause=codex_project_untrusted 未就绪的格不计入「L1 适用格」,completion 单列一项「Codex 信任」(source=l1、ready=false、新增字段 pending_user=true,不计入 done),next_actions 保留一条 codex_trust(计划已列不重复;计划预判已信任而 L1 仍显示未信任时补一条)——设计 Q5:炼境不写 Codex 信任表。其余原因的 Codex 格(含 blocked)照常计入;L1Report.all_applicable_ready 保持 L1 自身的原始判定。非 git 目录维持不能 done(acceptance ⑥);仓库子目录与 worktree / 子模块(.git 为文件)补 next_action repo_layout,说明「lefthook hook 挂载」只认项目根 .git/hooks、按当前口径到不了 done,并给出外层仓库根或实际 hooks 目录与怎样才能 done。由此 known_limits ④「Codex 未信任 → partial」改为 done + pending_user;⑤ 的判定口径不变,只补说明。 -### 📋 P8e — 投影收口与契约同步 -- status: todo +### 🔵 P8e — 投影收口与契约同步 +- status: in_progress - complexity: S -- files: src-tauri/src/mcp_tools.rs, src-tauri/src/lib.rs, src/lib/commands.ts, docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, AGENTS.md +- files: src-tauri/src/mcp_tools.rs, src-tauri/src/lib.rs, src/lib/commands.ts, docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, AGENTS.md(实际另含 src-tauri/src/commands/onboarding.rs(Tauri command 本身)、src-tauri/src/mcp_inject.rs(去掉生产入口的 dead_code 标注)、src-tauri/src/onboard/report.rs、onboard/apply.rs、onboard/apply/tests.rs(统筹决定 ⑩)) - depends: P8d - acceptance: "agent_verify: [cargo test mcp_tools] 全局和项目路由的 tools/list 都含 plan_onboarding 与 onboard_project;项目路由上强制本项目,传跨项目参数返回 scope_violation;onboard_project 的 required 含 expected_digest 和 confirmed;annotations 中 plan 为 readOnly、apply 为 idempotent。[pnpm typecheck] 通过。[MCP 全局] 对 enterprise 做 plan 全部 noop,做 apply 结果 done 且各步都是 converged。[读文件] project-context.yaml 已补 plan、digest 与状态语义(T1)。" - 说明:两个工具的 schema 单独成函数(避开 json! 递归上限),dispatch 里只留一行委托,减少与 A4(rmcp)迁移的冲突面;AGENTS.md 架构速查补 src-tauri/src/onboard/。 - P8d 审查转入(2026-10-03):① onboard_project / plan_onboarding 的 Tauri 投影用 async fn + tokio::task::spawn_blocking(同 probe_client_parity),不要用 #[tauri::command(async)] 包同步函数——那会让函数体占住一个 tokio worker,而 MCP 服务端跑在同一运行时、一键接入可能 30 秒以上。② 两个投影都接受可选参数 bypass_permissions 与 skip_targets(OnboardOptions,计划与应用须一致,否则 digest 不符);报告新增 written_files、repo_changes.onboard_files / other_changes 与 next_action commit,计划新增 root 与 skipped,写进 commands.ts 类型与 project-context.yaml。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8e):① MCP:mcp_tools 新增 plan_onboarding_schema / onboard_project_schema(各自单独成函数,选项参数 bypass_permissions(布尔)与 skip_targets(字符串数组,描述写明只有 manual_entry / shared_key 可跳过)由 add_onboard_option_properties 共用,保证两工具选项同名同义),插在旧的分步入口 apply_onboarding_pack 之前;dispatch 各一行委托 plan_onboarding_call / onboard_project_call——只做 JSON 类型解析(选项、expected_digest、confirmed 类型不对在任何 IO 之前报错,缺省 confirmed=false、缺省选项取 OnboardOptions::default),再调 mcp_inject::plan_onboarding / mcp_inject::onboard_project,不复制任何判定。onboard_project 的 required 为 project_id、expected_digest、confirmed;annotations:plan_onboarding 按 plan_ 前缀为 readOnly + idempotent,onboard_project 进幂等列表(非只读、非破坏、非 openWorld)。两者 schema 带 project_id,is_project_scoped_tool 自然放行:项目路由 project_id 改 const 且可省略,跨项目参数经 bind_project_scope 返回 scope_violation。描述写明先把计划给用户看、用户明确确认后再带 expected_digest 与 confirmed=true 调 onboard_project,两次选项须一致;Codex 需用户在 Codex 里信任目录(炼境不写信任表);Gitea 建仓默认公开、私有须 private=true。两者进 STRUCTURED_TOOLS:structuredContent 为 OnboardingPlan / OnboardReport 原样序列化,content 一行摘要(计划:根目录、文件 create/update/noop 计数、本机七步、阻断与跳过、bypassPermissions、Codex 信任、digest 与下一步;报告:本体 summary、待提交接入文件数、next_actions 名);refused / busy / failed / partial 以报告 status 返回,不作工具错误。list_all_projects 与 scan_unregistered_projects 的引导改为 plan_onboarding → 用户确认 → onboard_project。② Tauri:commands::onboarding 新增 plan_onboarding(project_id, options?) 与 onboard_project(project_id, expected_digest, confirmed, options?),均为 async fn + tokio::task::spawn_blocking(同 probe_client_parity),只一行委托 mcp_inject 生产入口;lib.rs 注册;mcp_inject 两个生产入口及 codex_trust 等辅助函数去掉 #[allow(dead_code)]。commands.ts 新增 OnboardOptions、OnboardingPlan(含 root、skipped)、OnboardReport(含 written_files、repo_changes.onboard_files / other_changes、completion[].pending_user)等类型与 planOnboarding / onboardProject 封装,字段与 Rust 序列化一一对应。③ 统筹决定(P8d 审查 ⑩):onboard::report 新增 l1_readiness——L1 的 Codex 列里 cause=codex_project_untrusted 的未就绪格计为「只等 Codex 信任」,不计入「L1 适用格」;有这类格时 completion 单列「Codex 信任」(source=l1、ready=false、CompletionItem 新增字段 pending_user=true),done 判定跳过 pending_user 项,next_actions 已有 codex_trust(计划预判)则不重复,没有(计划预判已信任而 L1 仍显示未信任)则补一条;其他列的同一归因、Codex 列其他归因(含 blocked / dependency)照常计入;L1Report.all_applicable_ready 不改写。新增 RepoLayout(NotGit / RepoRoot / Subdirectory{repo_root} / GitFile{hooks_dir})与只读的 report::repo_layout(.git 为目录 / 文件 / 不存在,git rev-parse --show-toplevel 与 --git-path hooks),apply 在报告阶段计算;仓库子目录与 worktree / 子模块给 next_action repo_layout,说明「lefthook hook 挂载」只认项目根 .git/hooks、按当前口径到不了 done(三端可用不受影响)、外层仓库根或实际 hooks 目录与怎样才能 done,partial 摘要指向它;非 git 维持不能 done(acceptance ⑥,由「项目文件改动可报告」未就绪说明,不另给 repo_layout)。done 摘要在有 pending_user 时注明「Codex 列 N 格待你在 Codex 里信任」。P8d 卡已记「统筹决定落地」一行。④ 契约:project-context.yaml 的 MCP 接口条目补两个工具的路由、schema、选项、确认契约与返回结构,身份上下文条目补 completion.pending_user、Codex 信任口径与 repo_layout,Tauri 契约条目补两个 async 命令与 Err / status 的分工;agent-button-system.md 清单补「一键接入子项目」一行与「只读计划 → 人确认同一 digest → 应用」约定;AGENTS.md 架构速查 onboard/ 行补「生产依赖由 mcp_inject 组装,MCP 与 Tauri 投影只一行委托」。 +- verification: "2026-10-03:全量 cargo test 413 passed / 0 failed / 6 ignored(master 402,新增 11 条),编译 0 warning;改动的 Rust 文件 rustfmt --check(skip_children)无差异;pnpm typecheck 通过,pnpm test(vitest)4 文件 16 条通过。cargo test mcp_tools 40 条通过,其中 P8e 新增 5 条:onboarding_tools_are_on_both_routes_with_confirmation_contract(全局与项目路由 tools/list 都含两工具;plan required=[project_id],onboard_project required 含 project_id、expected_digest、confirmed 且 confirmed 为 boolean;plan readOnly+idempotent+非破坏,apply 非只读+idempotent+非破坏+非 openWorld;两工具都有 bypass_permissions / skip_targets 且描述含 manual_entry、shared_key;描述含「给用户看」「用户明确确认」「在 Codex 里信任」「公开仓库」「private=true」;排在 apply_onboarding_pack 之前且相邻;项目路由 project_id 为 const=p1、不在 required,onboard_project required 为 [expected_digest, confirmed])、onboarding_tools_on_project_route_reject_cross_project_before_io(项目路由传 project_id=p2 两工具都 isError + structuredContent.code=scope_violation、文案含「绑定 project_id=p1」)、onboard_options_follow_schema_names_and_default(按 schema 声明的选项名构造入参解析正确;缺省与 null 为默认;两工具选项参数完全一致;三种类型错误文案)、onboarding_tools_reject_malformed_arguments_before_io(全局与项目路由:skip_targets 非数组、confirmed 非布尔、expected_digest 非字符串、bypass_permissions 非布尔均在 IO 前报错)、onboarding_responses_carry_structured_content_and_one_line_summary(序列化真实 OnboardingPlan / OnboardReport:structuredContent 原样、摘要含计数、本机步骤、阻断与 skip_targets 提示、digest、待提交接入文件数与 next_actions;status=done 不带 isError)。onboard::report 新增 4 条:codex_trust_only_cells_are_pending_user_and_do_not_block_done(Codex 列 C–F 只因未信任 → done、L1 适用格 ready、单列 1 项 pending_user、补 1 条 codex_trust、计划已列不重复、全就绪时无 pending 项)、other_not_ready_cells_still_block_done(Codex 列混入 auth_failed、别的列出现同一归因、Codex 列 blocked(dependency)、其他 L0 未就绪 → 均 partial)、repo_layout_next_action_explains_why_done_is_unreachable、repo_layout_detects_root_subdirectory_worktree_and_non_git(真实 git 仓库、子目录、git worktree 与普通目录)。onboard::apply 新增 2 条(测试替身 FakeRunner 增加 Codex 自省模拟、FakeLocator 可定位 Codex,lefthook 替身改为装到 git --git-path hooks 与真实 lefthook 一致):codex_untrusted_cells_are_pending_user_and_do_not_block_done(经真实 L1 判定链:codex mcp get 找不到 → C–F 为 codex_project_untrusted → status=done、pending_user 1 项、codex_trust 1 条;计划预判已信任时 L1 结论补 1 条;生效为全局地址 → partial、无 pending 项)、worktree_project_is_partial_and_next_action_explains_why(真实 git worktree:hook 装在主仓库,未就绪的只有「lefthook hook 挂载」,repo_layout 1 条,重跑同样 partial 且 lefthook 再跑);既有 project_in_repo_subdirectory_does_not_touch_outer_hooks 补断言(唯一障碍为 lefthook hook 挂载,repo_layout 含外层仓库名),first_apply 补断言(仓库根无 repo_layout、无 pending 项)。变异验证(全部改动先已 git add 暂存,用 Edit 注入、跑 cargo test --lib -- mcp_tools onboard:: commands::projects、git checkout 从暂存区还原,未用 sed)三轮 10 处全部被杀且失败集合与预期一致:第一轮 4 处——onboard_project 移出幂等列表、plan_onboarding 移出 STRUCTURED_TOOLS、l1_readiness 不限 Codex 列、repo_layout_next_action 恒为 None——恰好 6 条失败(路由契约、摘要、other_not_ready、repo_layout 说明及子目录 / worktree 两条 apply 用例)、126 条通过;第二轮 3 处——onboard_project 不进项目路由、done 判定不跳过 pending_user、.git 为文件也判仓库根——恰好 7 条失败、125 条通过;第三轮 2 处——L1 结论不补 codex_trust、选项解析丢弃 skip_targets——恰好 3 条失败、129 条通过。避开会绕过 IO 前校验的变异(如去掉 scope 绑定、去掉类型检查),以免测试进程调到生产入口。" +- 有意偏差:"① dispatch 的一行委托指向 mcp_tools 内的 plan_onboarding_call / onboard_project_call,再由它们调 mcp_inject 入口:中间只做 JSON 类型解析(类型错误须在任何 IO 之前报错,且与 register_project 的分发写法一致),判定仍全在本体。② onboard_project 的 annotations 为非只读、非破坏、幂等(重复执行收敛)、非 openWorld(只写本机与子项目文件、不出网);plan_onboarding 靠既有 plan_ 前缀规则得 readOnly。③ 统筹决定 ⑩ 的 pending_user 做成 CompletionItem 的通用布尔字段(serde default,目前只有「Codex 信任」一项会置 true),而不是新增 completion 来源;L1 适用格的 detail 注明有几格单列出去。④ 仓库子目录与 worktree 的 next_action 统一名为 repo_layout,同时提示「origin 指向 Gitea」也只读项目根 .git/config(同类口径问题,只说明、不改 verify);子模块与 worktree 同属「.git 为文件」一类,文案按 worktree 写。⑤ 非 git 目录不给 repo_layout(由 completion「项目文件改动可报告」未就绪说明,acceptance ⑥ 不变)。⑥ files 比卡片多,见 files。" +- 没验什么:"① acceptance 的 [MCP 全局](对 enterprise 做 plan 全部 noop、apply done 且各步 converged)与 P8f 的 register_project → plan_onboarding → onboard_project 链路:需合并后在运行中的炼境重编译再调写工具,按授权规则由统筹会话实测,本卡保持 🔵。② Tauri command 两个薄包装与 commands.ts 封装没有自动化测试、没在 UI 里调过(P8g 才有界面),只由 pnpm typecheck 与类型逐字段对照保证;Tauri 入参 options 的 camelCase / snake_case 映射(顶层参数 camelCase、OnboardOptions 内部字段 snake_case)按 Tauri 约定推断,未实机验证。③ MCP 客户端侧的工具超时:首次一键接入可能 30 秒以上,Codex 默认 tool_timeout_sec 等客户端限制没有实测。④ repo_layout 的 git rev-parse 与报告里其他 git 观测一样直接起 git、无超时(P8d known_limits ① 同类)。⑤ 统筹决定的 done 判定只在测试替身与单元层验证;真实 Codex 未信任目录的端到端(L1 显示 codex_project_untrusted → done + pending_user)待统筹实测。⑥ 本卡未做独立审查(S 级)。" ### 🔵 P8f — register_project 控制面投影 - status: in_progress diff --git a/AGENTS.md b/AGENTS.md index e3ae0ee..d5781a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具 src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入) 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/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) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index 3432979..259d887 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -137,6 +137,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: - 日常开发优先运行 `pnpm tauri dev`,Agent 直接消费同一 loopback MCP;正式安装包只在安装、自启动、升级或发布里程碑做一次验收,不作为每次提交的前置条件。 - 开发炼境前先从托盘完全退出正式安装版;不得把正式版与 `pnpm tauri dev` 并行运行。single-instance 是运行时兜底,不替代这一开发约定。 - 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。 @@ -186,6 +187,7 @@ Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机 | 前端单元测试 | `pnpm test` | exit 0,无 FAILED | | Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED | | 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/modules/.md` | 文件写入成功,格式合法 | diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index c0ac6f1..fc7ad4d 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -18,8 +18,10 @@ contracts: - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" - "src-tauri/src/onboard/probe_l1.rs" + - "src-tauri/src/onboard/plan.rs" + - "src-tauri/src/onboard/report.rs" - "src-tauri/src/commands/projects.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):持接入写锁先清理 MCP 配置、再在同一事务内解除 Gitea 仓库绑定并删登记(与一键接入串行,P8d 审查 ①),释放写锁后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client;检查期间本项目路由上的全部 initialize(含探针的 claude mcp get 健康检查与零模型 init,均以 claude-code 身份连接)也不计入,宁可多提示一次重启。协议重放只证明服务端视角:客户端自己未连上时 D–F 记 blocked,客户端看不到某工具(Claude init 工具名、Codex enabled_tools/disabled_tools)时对应格记 tools_mismatch;配置里的 helper 只有逐字等于炼境托管写法(当前或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest)才执行,否则记 unknown(manual_config);零模型 init 带 --settings {\"disableAllHooks\":true},只挂 type/url/headersHelper。" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):持接入写锁先清理 MCP 配置、再在同一事务内解除 Gitea 仓库绑定并删登记(与一键接入串行,P8d 审查 ①),释放写锁后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client;检查期间本项目路由上的全部 initialize(含探针的 claude mcp get 健康检查与零模型 init,均以 claude-code 身份连接)也不计入,宁可多提示一次重启。协议重放只证明服务端视角:客户端自己未连上时 D–F 记 blocked,客户端看不到某工具(Claude init 工具名、Codex enabled_tools/disabled_tools)时对应格记 tools_mismatch;配置里的 helper 只有逐字等于炼境托管写法(当前或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest)才执行,否则记 unknown(manual_config);零模型 init 带 --settings {\"disableAllHooks\":true},只挂 type/url/headersHelper。一键接入两个工具(P8e)plan_onboarding(readOnly、幂等)与 onboard_project(非只读、非破坏、幂等:重复执行收敛)在全局与项目路由都开放:项目路由把 project_id 绑定为本项目(schema 改 const、可省略),跨项目参数返回 scope_violation;schema 各自单独成函数,dispatch 只一行委托 mcp_inject::plan_onboarding / mcp_inject::onboard_project(与同名 Tauri command 同一本体,投影不复制判定)。plan_onboarding 的 required 为 project_id,onboard_project 的 required 为 project_id、expected_digest、confirmed;两者共用可选选项 bypass_permissions(布尔,默认 true)与 skip_targets(字符串数组,取 plan 的 blocked[].target,只有 manual_entry / shared_key 可跳过,其余列进来照旧阻断),计划与应用必须同一组选项,否则 digest 不符被拒;选项、expected_digest、confirmed 类型不对在任何 IO 之前报错,缺省 confirmed 视为 false。描述约束 agent:先把计划(要改的文件、blocked、bypass_permissions、next_actions)给用户看,用户明确确认后才带同一 digest 与 confirmed=true 调 onboard_project;Codex 需用户在 Codex 里信任目录(炼境不写信任表);Gitea 建仓工具默认建公开仓库,私有须显式 private=true。两者进 STRUCTURED_TOOLS:structuredContent 为 OnboardingPlan / OnboardReport 原样结构(与 commands.ts 同名类型一致),content 为一行摘要(计划:文件动作计数、本机七步、阻断与跳过、bypassPermissions、Codex 信任、digest 与下一步;报告:本体 summary、待提交接入文件数与 next_actions 名);refused / busy / failed / partial 以报告 status 返回,不作工具错误。list_all_projects 与 scan_unregistered_projects 的引导改为 plan_onboarding → 用户确认 → onboard_project(inject_mcp 只做本机 MCP 注入)。" - label: 被管理项目身份上下文 paths: @@ -28,13 +30,13 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。一键接入编排本体 onboard::apply(P8d;MCP / Tauri 投影归 P8e,生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project):计划含项目根目录 root(将要写入的目录)、逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、skipped、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest(绑定 root、文件、本机步骤、阻断项与跳过项);选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写);选项 skip_targets 是人对阻断项逐项选「跳过」的 target:只有 manual_entry(手写同名条目)与 shared_key(共享项目键)可跳过,移入 skipped、对应一端 apply 时记 skipped(manual_kept|other_project)、不调用该端适配器、文件原样保留,身份冲突、混合文件与 needs_human 列进来也照旧阻断;跳过不等于该端可用,done 仍按三端可用子集判定。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重新解析登记(等锁期间登记被删除 → failed;根目录或端口变了 → refused 并附当前计划),再重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后在写锁内按依赖执行 identity、project_files(与计划同一写入序列)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…);Codex 配置即项目 .codex/config.toml,由 project_files 写入,codex 步只复核,本次写过记 applied),释放写锁后执行 git_hooks(lefthook install,非 git 仓库或仓库子目录 not_applicable,缺可执行文件 needs_human,本地可执行文件存在却起不来 failed 并附启动错误);每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变);written_files 为本次实际写入的项目文件;repo_changes 为 git status --porcelain --untracked-files=all -- . 与 git diff --stat -- .(限定在项目根内;非 git 为 unavailable),并分出 onboard_files(本次写入的,加上接入写入序列管理且仍未跟踪的,路径相对项目根)与 other_changes(用户原有改动等);建议提交信息与 next_actions 的 commit 一项(git add -- )只针对接入文件,炼境不 add、不 commit;next_actions 含 commit、Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。一键接入执行了写入时另补一条 category=mcp_inject 的事件(token、shim 与三端步骤都完成记 info,否则 warn),get_diagnostics 的注入状态与设置页注入告警据此更新。删除登记(UI delete_project 与 unregister_project)在同一把接入写锁内先清理 MCP 配置、再删登记的数据库部分;端口变更的批量重注入在写锁内取目标快照。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。一键接入编排本体 onboard::apply(P8d;生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project,P8e 的 MCP 工具与 Tauri command 同名投影都只一行委托它们):计划含项目根目录 root(将要写入的目录)、逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、skipped、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest(绑定 root、文件、本机步骤、阻断项与跳过项);选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写);选项 skip_targets 是人对阻断项逐项选「跳过」的 target:只有 manual_entry(手写同名条目)与 shared_key(共享项目键)可跳过,移入 skipped、对应一端 apply 时记 skipped(manual_kept|other_project)、不调用该端适配器、文件原样保留,身份冲突、混合文件与 needs_human 列进来也照旧阻断;跳过不等于该端可用,done 仍按三端可用子集判定。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重新解析登记(等锁期间登记被删除 → failed;根目录或端口变了 → refused 并附当前计划),再重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后在写锁内按依赖执行 identity、project_files(与计划同一写入序列)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…);Codex 配置即项目 .codex/config.toml,由 project_files 写入,codex 步只复核,本次写过记 applied),释放写锁后执行 git_hooks(lefthook install,非 git 仓库或仓库子目录 not_applicable,缺可执行文件 needs_human,本地可执行文件存在却起不来 failed 并附启动错误);每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(全部步骤 converged/applied/skipped/not_applicable,且 completion 中不带 pending_user 的项全部 ready:verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变)。completion 每项为 {item, source(l0|clients|l1|repo), ready, detail, pending_user};统筹决定(P8d 审查 ⑩,P8e 实现):L1 的 Codex 列里仅因 cause=codex_project_untrusted 未就绪的格只等用户在 Codex 里信任目录(炼境不写 Codex 信任表),不计入「L1 适用格」,completion 单列一项「Codex 信任」(source=l1、ready=false、pending_user=true,不计入 done),next_actions 保留一条 codex_trust(计划已列则不重复,计划预判已信任而 L1 仍未信任时补一条);Codex 列其余原因(含 blocked)照常计入;L1Report.all_applicable_ready 保持 L1 自身的原始判定。非 git 目录 repo_changes 为 unavailable,到不了 done;项目根是仓库子目录或 .git 为文件(worktree、子模块)时 verify 的「lefthook hook 挂载」只认项目根 .git/hooks、不会就绪,到不了 done,next_actions 补一条 repo_layout 说明原因(外层仓库根或实际 hooks 目录)与怎样才能 done;written_files 为本次实际写入的项目文件;repo_changes 为 git status --porcelain --untracked-files=all -- . 与 git diff --stat -- .(限定在项目根内;非 git 为 unavailable),并分出 onboard_files(本次写入的,加上接入写入序列管理且仍未跟踪的,路径相对项目根)与 other_changes(用户原有改动等);建议提交信息与 next_actions 的 commit 一项(git add -- )只针对接入文件,炼境不 add、不 commit;next_actions 含 commit、Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、仓库布局(repo_layout)、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。一键接入执行了写入时另补一条 category=mcp_inject 的事件(token、shim 与三端步骤都完成记 info,否则 warn),get_diagnostics 的注入状态与设置页注入告警据此更新。删除登记(UI delete_project 与 unregister_project)在同一把接入写锁内先清理 MCP 配置、再删登记的数据库部分;端口变更的批量重注入在写锁内取目标快照。" - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时;在 Tauri 异步运行时的任务里执行,不占界面线程,但会占住一个运行时 worker,同 buff.rs、groups.rs 的写法),入参与返回不变;耗时更长的投影(P8e 的 onboard_project / plan_onboarding)用 async fn + spawn_blocking,与 probe_client_parity 一致。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时;在 Tauri 异步运行时的任务里执行,不占界面线程,但会占住一个运行时 worker,同 buff.rs、groups.rs 的写法),入参与返回不变。一键接入的 Tauri 投影(P8e)plan_onboarding(projectId, options?) → OnboardingPlan 与 onboard_project(projectId, expectedDigest, confirmed, options?) → OnboardReport 为 async fn + tokio::task::spawn_blocking(与 probe_client_parity 一致;不用 #[tauri::command(async)] 包同步函数,以免占住 MCP 服务端所在运行时的 worker 30 秒以上),与 MCP 同名工具一样只委托 mcp_inject 生产入口;options 可省略(OnboardOptions 默认:bypass_permissions=true、skip_targets 为空),计划与应用须传同一组;plan 的错误(未登记、路径无效等)以 Err 返回,onboard_project 的 refused / busy / failed / partial 一律以报告 status 返回,Err 只表示线程异常。commands.ts 的 OnboardOptions、OnboardingPlan、OnboardReport(含 completion[].pending_user、repo_changes.onboard_files / other_changes、written_files、plan.root / skipped)与 Rust 序列化结构一一对应。" architecture_docs: - "AGENTS.md" diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index c369c8c..19d2d04 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -488,6 +488,44 @@ pub async fn probe_client_parity( .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, +) -> Result { + 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, +) -> Result { + 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 检查链,判定逻辑纯函数化可单测)────── /// 单项验收结果。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f158ccf..5b162fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -443,6 +443,8 @@ pub fn run() { // onboarding pack commands::onboarding::apply_onboarding_pack, commands::onboarding::probe_client_parity, + commands::onboarding::plan_onboarding, + commands::onboarding::onboard_project, // agent infrastructure commands::agent_infra::scan_agent_infra_stack, commands::agent_infra::generate_agent_infra_files, diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index bca1365..a6c1bd7 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -1701,7 +1701,8 @@ pub fn client_static_status(project_id: &str) -> Vec { // ── 一键接入(P8c 计划 / P8d 编排):生产入口 ──────────────────────────────────── // 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。 -// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。 +// 对外投影(P8e):MCP 工具 plan_onboarding / onboard_project(mcp_tools)与同名 Tauri command +// (commands::onboarding)都只在这两个入口上做一行委托,不在投影里复制任何判定。 /// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 /// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源): @@ -1795,8 +1796,6 @@ fn with_onboard_deps( /// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的 /// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。 -/// P8e 接入 MCP / Tauri 投影前暂无生产调用方。 -#[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, options: &crate::onboard::apply::OnboardOptions, @@ -1808,7 +1807,6 @@ pub(crate) fn plan_onboarding( /// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。 /// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的 /// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。 -#[allow(dead_code)] pub(crate) fn onboard_project( project_id: &str, expected_digest: &str, @@ -1859,7 +1857,6 @@ fn codex_home() -> Option { /// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任, /// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。 /// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。 -#[allow(dead_code)] fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust { use crate::onboard::plan::CodexTrust; 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 仓库内返回 None。 -#[allow(dead_code)] fn git_main_worktree_root(root: &std::path::Path) -> Option { let output = crate::silent_cmd("git") .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) @@ -1896,7 +1892,6 @@ fn git_main_worktree_root(root: &std::path::Path) -> Option { PathBuf::from(common.trim()).parent().map(PathBuf::from) } -#[allow(dead_code)] fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool { let trusted = codex_trusted_projects(config); candidates diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index b6180d5..fc56244 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -312,7 +312,7 @@ pub fn tools_list_result() -> Value { }, { "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": [] } }, { @@ -410,11 +410,131 @@ pub fn tools_list_result() -> Value { .position(|tool| tool["name"] == "scan_unregistered_projects") .map_or(tools.len(), |index| index + 1); 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); 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 { + 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::>()? + } + 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 { + 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 { + 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! 宏递归上限。 /// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。 fn parity_probe_tool_schema() -> Value { @@ -654,6 +774,7 @@ fn apply_tool_annotations(result: &mut Value) { name, "inject_mcp" | "apply_onboarding_pack" + | "onboard_project" | "setup_webhook" | "sync_blueprint_rules" | "backfill_notes_archive" @@ -796,6 +917,8 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .unwrap_or("接入验收结构化结果已返回") .to_string(), "probe_client_parity" => parity_probe_summary(value), + "plan_onboarding" => onboarding_plan_summary(value), + "onboard_project" => onboard_report_summary(value), "register_project" => { let summary = format!( "已登记项目: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 = 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 = 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 { const STRUCTURED_TOOLS: &[&str] = &[ "get_delivery_status", @@ -874,6 +1103,8 @@ fn tool_success_response(tool_name: &str, text: String) -> Value { "verify_onboarding", "probe_client_parity", "register_project", + "plan_onboarding", + "onboard_project", ]; if STRUCTURED_TOOLS.contains(&tool_name) { if let Ok(structured) = serde_json::from_str::(&text) { @@ -1540,6 +1771,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option plan_onboarding_call(&args), + "onboard_project" => onboard_project_call(&args), "verify_onboarding" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let root = resolve_project_root(pid)?; @@ -2262,7 +2495,9 @@ fn render_all_projects( 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) } @@ -2395,7 +2630,7 @@ fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result 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 { + 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 与登记重复检查同一口径——已登记目录换了写法 /// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。 #[test] diff --git a/src-tauri/src/onboard/apply.rs b/src-tauri/src/onboard/apply.rs index 92083d7..9120fd9 100644 --- a/src-tauri/src/onboard/apply.rs +++ b/src-tauri/src/onboard/apply.rs @@ -407,6 +407,7 @@ pub fn onboard_project( repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed), written_files: applied.written, head: report::head_check(&resolved.root, head_before), + repo_layout: report::repo_layout(&resolved.root), started, }) } diff --git a/src-tauri/src/onboard/apply/tests.rs b/src-tauri/src/onboard/apply/tests.rs index c34684c..3cf81a7 100644 --- a/src-tauri/src/onboard/apply/tests.rs +++ b/src-tauri/src/onboard/apply/tests.rs @@ -7,8 +7,8 @@ use crate::credential_store::MemoryCredentialStore; use crate::onboard::clients::{ClientOutcome, ProjectFacts}; use crate::onboard::plan::{BlockReason, FileAction}; use crate::onboard::probe_l1::{ - Cause, CellState, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Located, - McpHttp, ProcessOutput, StaticFinding, + Cause, CellState, Check, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, + Located, McpHttp, ProcessOutput, StaticFinding, }; use crate::onboard::report::RepoChanges; use std::collections::BTreeMap; @@ -201,16 +201,26 @@ impl OnboardHost for FakeHost { #[derive(Clone, Copy, PartialEq, Eq)] enum Lefthook { - /// 模拟 lefthook install:写 .git/hooks/pre-commit 并退出 0 + /// 模拟 lefthook install:写 git 的 hooks 目录下的 pre-commit 并退出 0 Installs, /// 起不来(不存在) 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 不占写锁)。 struct FakeRunner { lefthook: Mutex, + codex: Mutex, specs: Mutex>, write_lock: Arc>, lock_free_during_install: Mutex>, @@ -220,12 +230,40 @@ impl FakeRunner { fn new(write_lock: Arc>) -> Self { Self { lefthook: Mutex::new(Lefthook::Installs), + codex: Mutex::new(CodexReply::Untrusted), specs: Mutex::new(Vec::new()), write_lock, 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 { self.specs .lock() @@ -239,8 +277,12 @@ impl FakeRunner { impl ProcessRunner for FakeRunner { fn run(&self, spec: &ProcessSpec) -> Result { self.specs.lock().unwrap().push(spec.clone()); - if !spec.program.to_string_lossy().contains("lefthook") { - return Err("测试执行器只模拟 lefthook".into()); + let program = spec.program.to_string_lossy(); + if program.contains("codex") { + return Ok(self.codex(spec)); + } + if !program.contains("lefthook") { + return Err("测试执行器只模拟 lefthook 与 codex".into()); } self.lock_free_during_install .lock() @@ -249,7 +291,15 @@ impl ProcessRunner for FakeRunner { match *self.lefthook.lock().unwrap() { Lefthook::Missing => Err("program not found".into()), 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::write(hooks.join("pre-commit"), "#!/bin/sh\n# lefthook 测试替身\n") .unwrap(); @@ -262,16 +312,22 @@ impl ProcessRunner for FakeRunner { } } -/// 本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。 -/// 记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。 +/// 默认本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。 +/// codex_installed 时定位到 Codex(自省经 FakeRunner 模拟)。记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。 struct FakeLocator { located: Mutex>, + codex_installed: AtomicBool, } impl ClientLocator for FakeLocator { fn locate(&self, client: ClientId) -> Option { 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 { @@ -387,6 +443,7 @@ impl Harness { runner: FakeRunner::new(lock.clone()), locator: FakeLocator { located: Mutex::new(Vec::new()), + codex_installed: AtomicBool::new(false), }, lock, root, @@ -665,6 +722,9 @@ fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() { } // 改了配置:提示重开已打开的会话(P4a 审查 ③:不据 reload_required 推断已重载) assert!(listed.contains(&"reopen_sessions")); + // 项目根就是仓库根:没有「仓库布局」说明,也没有只等人操作的项 + assert!(!listed.contains(&"repo_layout"), "{listed:?}"); + assert!(report.completion.iter().all(|item| !item.pending_user)); // L1 三端都检查过(本机替身「未安装」,不计失败) 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"), "接入文件路径相对项目根(在项目根执行 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); 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 审查处置的回归 ───────────────────────────────────────────────────────── /// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。 diff --git a/src-tauri/src/onboard/report.rs b/src-tauri/src/onboard/report.rs index 229a489..c8be231 100644 --- a/src-tauri/src/onboard/report.rs +++ b/src-tauri/src/onboard/report.rs @@ -5,10 +5,15 @@ //! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪, //! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然 //! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。 +//! +//! 统筹决定(2026-10-03,P8d 审查 ⑩):L1 的 Codex 列里仅因 codex_project_untrusted 未就绪的格只等用户在 Codex 里 +//! 信任该目录(炼境不写 Codex 信任表,设计 Q5),不计入「L1 适用格」,在 completion 单列为 pending_user「Codex 信任」、 +//! next_actions 保留 codex_trust;其余原因的 Codex 格照常计入。非 git 目录仍到不了 done;仓库子目录与 worktree +//! (.git 是文件)的「lefthook hook 挂载」检查只认项目根 .git/hooks,同样到不了 done,由 next_action repo_layout 说明原因。 use super::clients::{ClientId, ClientOutcome}; use super::plan::{BlockedItem, NextAction, OnboardingPlan}; -use super::probe_l1::{Cell, L1Report}; +use super::probe_l1::{Cause, Cell, L1Report}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::time::Instant; @@ -130,6 +135,35 @@ pub struct CompletionItem { pub source: String, pub ready: bool, pub detail: String, + /// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false, + /// 但不计入 done 判定;对应的 next_action 保留 + #[serde(default)] + pub pending_user: bool, +} + +impl CompletionItem { + fn new(item: impl Into, source: &str, ready: bool, detail: impl Into) -> Self { + Self { + item: item.into(), + source: source.into(), + ready, + detail: detail.into(), + pending_user: false, + } + } +} + +/// 项目根在 git 里的位置:决定改动能否报告、「lefthook hook 挂载」能否就绪(该检查只认项目根 .git/hooks)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RepoLayout { + /// 不在 git 工作区内:repo_changes 为 unavailable,到不了 done + NotGit, + /// 项目根就是仓库根(.git 为目录) + RepoRoot, + /// 项目根是外层仓库的子目录(repo_root 为外层仓库根,取不到时为空) + Subdirectory { repo_root: String }, + /// 项目根的 .git 是文件:linked worktree 或子模块(hooks_dir 为 hook 实际所在目录,取不到时为空) + GitFile { hooks_dir: String }, } /// 项目文件的待提交改动:炼境只报告、不 add、不 commit。 @@ -320,6 +354,7 @@ pub(crate) struct AppliedFacts { pub repo_changes: RepoChanges, pub written_files: Vec, pub head: Option, + pub repo_layout: RepoLayout, pub started: Instant, } @@ -339,38 +374,65 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { }); } } - None => completion.push(CompletionItem { - item: name.clone(), - source: "l0".into(), - ready: *pass, - detail: detail.clone(), - }), + None => completion.push(CompletionItem::new( + name.clone(), + "l0", + *pass, + detail.clone(), + )), } } for (client, outcome) in &facts.clients_after { - completion.push(CompletionItem { - item: client_label(*client).into(), - source: "clients".into(), - ready: outcome.is_settled(), - detail: outcome.describe(), - }); + completion.push(CompletionItem::new( + client_label(*client), + "clients", + outcome.is_settled(), + outcome.describe(), + )); + } + let (l1_ready, awaiting_trust) = l1_readiness(&facts.l1); + completion.push(CompletionItem::new( + "L1 适用格", + "l1", + l1_ready, + l1_summary(&facts.l1, awaiting_trust), + )); + if awaiting_trust > 0 { + completion.push(CompletionItem { + pending_user: true, + ..CompletionItem::new( + "Codex 信任", + "l1", + false, + format!( + "Codex 列 {awaiting_trust} 格仅因目录未受 Codex 信任而未就绪(codex_project_untrusted):Codex 只加载受信任目录的\ + 项目 .codex/config.toml,信任由你在 Codex 里操作、炼境不写 Codex 信任表;不计入 done,信任后重跑一键接入\ + 或 probe_client_parity 即就绪" + ), + ) + }); + if !next_actions + .iter() + .any(|action| action.action == "codex_trust") + { + next_actions.push(NextAction { + action: "codex_trust".into(), + detail: "L1 显示 Codex 在该目录看不到项目配置(目录未受信任):请在 Codex 里信任该目录(炼境不改 Codex 信任表),\ + 之后新开的 Codex 会话才加载项目 .codex/config.toml" + .into(), + }); + } } - completion.push(CompletionItem { - item: "L1 适用格".into(), - source: "l1".into(), - ready: facts.l1.all_applicable_ready, - detail: l1_summary(&facts.l1), - }); completion.push(match &facts.repo_changes { RepoChanges::Available { onboard_files, other_changes, .. - } => CompletionItem { - item: "项目文件改动可报告".into(), - source: "repo".into(), - ready: true, - detail: format!( + } => CompletionItem::new( + "项目文件改动可报告", + "repo", + true, + format!( "{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}", onboard_files.len(), if *other_changes > 0 { @@ -379,25 +441,22 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { String::new() } ), - }, - RepoChanges::Unavailable { reason } => CompletionItem { - item: "项目文件改动可报告".into(), - source: "repo".into(), - ready: false, - detail: reason.clone(), - }, + ), + RepoChanges::Unavailable { reason } => { + CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone()) + } }); if let Some(head) = &facts.head { - completion.push(CompletionItem { - item: "HEAD 未变".into(), - source: "repo".into(), - ready: head.unchanged, - detail: if head.unchanged { - "写入前后 HEAD 相同(炼境未提交)".into() + completion.push(CompletionItem::new( + "HEAD 未变", + "repo", + head.unchanged, + if head.unchanged { + "写入前后 HEAD 相同(炼境未提交)" } else { - "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into() + "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属" }, - }); + )); } 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") { next_actions.push(NextAction { action: "restart_dsh_gui".into(), @@ -471,9 +533,10 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { .filter(|s| !s.status.is_settled()) .map(|s| s.step.clone()) .collect(); + // pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done let not_ready: Vec = completion .iter() - .filter(|item| !item.ready) + .filter(|item| !item.ready && !item.pending_user) .map(|item| item.item.clone()) .collect(); let status = if unsettled.is_empty() && not_ready.is_empty() { @@ -482,9 +545,16 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { OnboardStatus::Partial }; let changed = commit_message.is_some(); + let pending_note = if awaiting_trust > 0 { + format!( + ";Codex 列 {awaiting_trust} 格待你在 Codex 里信任该目录后就绪(completion 的 pending_user,不计入 done)" + ) + } else { + String::new() + }; let summary = match status { OnboardStatus::Done => format!( - "done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)", + "done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)", facts.steps.len(), if changed { "接入文件有待提交改动(炼境不提交,只 add onboard_files);" @@ -494,14 +564,11 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { next_actions.len() ), _ => format!( - "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}", + "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}", unsettled.join("、"), not_ready.join("、"), - if next_actions - .iter() - .any(|action| action.action == "codex_trust") - { - "(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)" + if layout_blocks_done { + ";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout" } 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 .clients .iter() @@ -545,14 +612,72 @@ fn l1_summary(report: &L1Report) -> String { .collect(); let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count(); format!( - "{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms", + "{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms{}", report.clients.len(), cells.len(), 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 { + const WHY: &str = "verify_onboarding 的「lefthook hook 挂载」只认项目根 .git/hooks/pre-commit,这一项不会就绪,\ + 所以本项目按当前检查口径到不了 done(三端可用不受影响);「origin 指向 Gitea」同样只读项目根 .git/config,\ + gitea_repo 一项请按所在仓库的实际 origin 判断"; + let unknown = |text: &str| { + if text.is_empty() { + "(未能取得)".to_string() + } else { + text.to_string() + } + }; + let detail = match layout { + RepoLayout::NotGit | RepoLayout::RepoRoot => return None, + RepoLayout::Subdirectory { repo_root } => format!( + "项目根是 git 仓库 {} 的子目录:hook 归外层仓库管理,炼境不改写外层仓库的 hooks(git_hooks 记 not_applicable);\ + {WHY}。要到 done:在仓库根登记并接入,或让该目录自成仓库", + unknown(repo_root) + ), + RepoLayout::GitFile { hooks_dir } => format!( + "项目根的 .git 是文件(git worktree 或子模块),hook 实际在 {};{WHY},且 git_hooks 步每次都会重跑 lefthook install。\ + 要到 done:worktree 请在主仓库根登记并接入(worktree 与主仓库共用 hooks)", + unknown(hooks_dir) + ), + }; + Some(NextAction { + action: "repo_layout".into(), + detail, + }) +} + /// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计): /// 有新文件记「接入」,只有修改记「收敛」。 fn suggested_commit_message(changes: &RepoChanges) -> Option { @@ -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 里的位置(只读):.git 为目录是仓库根,为文件是 worktree 或子模块,不存在而在工作区内是子目录。 +pub(crate) fn repo_layout(root: &Path) -> RepoLayout { + if !is_git_repo(root) { + return RepoLayout::NotGit; + } + let trimmed = |result: Result| { + result.map(|out| out.trim().to_string()).unwrap_or_default() + }; + let dot_git = root.join(".git"); + if dot_git.is_dir() { + RepoLayout::RepoRoot + } else if dot_git.is_file() { + RepoLayout::GitFile { + hooks_dir: trimmed(git_stdout( + root, + &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], + )), + } + } else { + RepoLayout::Subdirectory { + repo_root: trimmed(git_stdout(root, &["rev-parse", "--show-toplevel"])), + } + } +} + /// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。 pub(crate) fn git_head(root: &Path) -> Option { git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"]) @@ -945,4 +1095,397 @@ mod tests { assert_eq!(level, "warn"); assert!(message.contains("claude"), "{message}"); } + + // ── 统筹决定(P8d 审查 ⑩):Codex 信任单列 pending_user、仓库布局说明 ────────────────────── + + use crate::onboard::probe_l1::{CellState, Check, ClientColumn, EvidenceLevel, ProbeProfile}; + + const CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F]; + + fn cell(state: CellState, cause: Option) -> Cell { + Cell { + state, + cause, + evidence: EvidenceLevel::Protocol, + detail: String::new(), + dep: None, + } + } + + /// 一端的 L1 列:未列出的检查项为 ready。 + fn column(client: ClientId, overrides: &[(Check, CellState, Cause)]) -> ClientColumn { + ClientColumn { + client, + version: None, + route: None, + cells: CHECKS + .iter() + .map(|check| { + let value = overrides + .iter() + .find(|(name, _, _)| name == check) + .map_or(cell(CellState::Ready, None), |(_, state, cause)| { + cell(*state, Some(*cause)) + }); + (*check, value) + }) + .collect(), + duration_ms: 1, + } + } + + fn l1(columns: Vec) -> L1Report { + let all_applicable_ready = columns + .iter() + .all(|column| column.cells.values().all(Cell::is_acceptable)); + L1Report { + project_id: "p".into(), + tier: "l1".into(), + probe_spec_version: 1, + profile: ProbeProfile::Project, + expected_route: "http://127.0.0.1:27190/p/p/mcp".into(), + expected_tools: 1, + nonce: "n".into(), + clients: columns, + all_applicable_ready, + duration_ms: 1, + note: String::new(), + } + } + + const UNTRUSTED: [(Check, CellState, Cause); 4] = [ + (Check::C, CellState::Fail, Cause::CodexProjectUntrusted), + (Check::D, CellState::Fail, Cause::CodexProjectUntrusted), + (Check::E, CellState::Fail, Cause::CodexProjectUntrusted), + (Check::F, CellState::Fail, Cause::CodexProjectUntrusted), + ]; + + /// 九步全部收敛、L0 与只读判定全部就绪的已执行写入;L1、仓库布局、额外 L0 检查与计划的 next_actions 由用例给出。 + fn facts( + l1: L1Report, + repo_layout: RepoLayout, + extra_l0: &[(&str, bool)], + plan_next_actions: Vec, + ) -> AppliedFacts { + let mut l0_checks = vec![ + ("AGENTS.md 管理块版本".to_string(), true, "ok".to_string()), + ( + "origin 指向 Gitea".to_string(), + false, + "无 origin".to_string(), + ), + ]; + l0_checks.extend( + extra_l0 + .iter() + .map(|(name, pass)| (name.to_string(), *pass, String::new())), + ); + AppliedFacts { + project_id: "p".into(), + digest: "d".into(), + bypass_permissions: false, + plan_next_actions, + steps: [ + "identity", + "project_files", + "git_hooks", + "product_group_buff", + "token", + "shim", + "claude", + "codex", + "dsh", + ] + .iter() + .map(|name| StepReport::new(name, StepStatus::Converged, "一致")) + .collect(), + l0_checks, + clients_after: [ClientId::Claude, ClientId::Codex, ClientId::Dsh] + .into_iter() + .map(|client| (client, ClientOutcome::Converged)) + .collect(), + l1, + repo_changes: RepoChanges::Available { + status_porcelain: Vec::new(), + diff_stat: String::new(), + onboard_files: Vec::new(), + other_changes: 0, + }, + written_files: Vec::new(), + head: Some(HeadCheck { + before: Some("h".into()), + after: Some("h".into()), + unchanged: true, + }), + repo_layout, + started: Instant::now(), + } + } + + fn ready_matrix(codex: &[(Check, CellState, Cause)]) -> L1Report { + l1(vec![ + column(ClientId::Claude, &[]), + column(ClientId::Codex, codex), + column(ClientId::Dsh, &[]), + ]) + } + + fn count_actions(report: &OnboardReport, name: &str) -> usize { + report + .next_actions + .iter() + .filter(|action| action.action == name) + .count() + } + + #[test] + fn codex_trust_only_cells_are_pending_user_and_do_not_block_done() { + let report = applied_report(facts( + ready_matrix(&UNTRUSTED), + RepoLayout::RepoRoot, + &[], + Vec::new(), + )); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + assert!( + !report.l1.as_ref().unwrap().all_applicable_ready, + "L1 原始判定保留" + ); + let l1_item = report + .completion + .iter() + .find(|item| item.item == "L1 适用格") + .unwrap(); + assert!(l1_item.ready && !l1_item.pending_user); + assert!( + l1_item.detail.contains("4 格仅因 Codex 未信任"), + "{}", + l1_item.detail + ); + let pending: Vec<&CompletionItem> = report + .completion + .iter() + .filter(|item| item.pending_user) + .collect(); + assert_eq!(pending.len(), 1); + assert_eq!( + ( + pending[0].item.as_str(), + pending[0].source.as_str(), + pending[0].ready + ), + ("Codex 信任", "l1", false) + ); + assert_eq!(count_actions(&report, "codex_trust"), 1, "L1 的结论补一条"); + assert!(report.summary.starts_with("done:"), "{}", report.summary); + assert!( + report.summary.contains("pending_user"), + "{}", + report.summary + ); + let json = serde_json::to_value(&pending[0]).unwrap(); + assert_eq!(json["pending_user"], true, "pending_user 进入契约"); + + // 计划已列 codex_trust:不重复 + let planned = applied_report(facts( + ready_matrix(&UNTRUSTED), + RepoLayout::RepoRoot, + &[], + vec![NextAction { + action: "codex_trust".into(), + detail: "计划预判".into(), + }], + )); + assert_eq!(count_actions(&planned, "codex_trust"), 1); + assert_eq!(planned.next_actions[0].detail, "计划预判"); + + // 全部就绪:没有 pending_user 项,也不补 codex_trust + let clean = applied_report(facts( + ready_matrix(&[]), + RepoLayout::RepoRoot, + &[], + Vec::new(), + )); + assert_eq!(clean.status, OnboardStatus::Done); + assert!(clean.completion.iter().all(|item| !item.pending_user)); + assert_eq!(count_actions(&clean, "codex_trust"), 0); + assert!(!clean.summary.contains("pending_user")); + } + + #[test] + fn other_not_ready_cells_still_block_done() { + // Codex 列混入别的归因:只单列 codex_project_untrusted 的格,其余照常计入 + let mut mixed = UNTRUSTED.to_vec(); + mixed[3] = (Check::F, CellState::Fail, Cause::AuthFailed); + let report = applied_report(facts( + ready_matrix(&mixed), + RepoLayout::RepoRoot, + &[], + Vec::new(), + )); + assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}"); + assert!(report + .completion + .iter() + .any(|item| item.item == "L1 适用格" && !item.ready)); + assert!(report + .completion + .iter() + .any(|item| item.pending_user && item.detail.contains("3 格"))); + + // 只认 Codex 列:别的列出现同一归因也照常计入 + let other_column = l1(vec![ + column( + ClientId::Claude, + &[(Check::C, CellState::Fail, Cause::CodexProjectUntrusted)], + ), + column(ClientId::Codex, &[]), + column(ClientId::Dsh, &[]), + ]); + let report = applied_report(facts(other_column, RepoLayout::RepoRoot, &[], Vec::new())); + assert_eq!(report.status, OnboardStatus::Partial); + assert!(report.completion.iter().all(|item| !item.pending_user)); + + // blocked(依赖步骤未完成)的 Codex 格不是「只等信任」 + let blocked = ready_matrix(&[(Check::C, CellState::Blocked, Cause::Dependency)]); + let report = applied_report(facts(blocked, RepoLayout::RepoRoot, &[], Vec::new())); + assert_eq!(report.status, OnboardStatus::Partial); + + // pending_user 只豁免 L1 的 Codex 信任:别的未就绪检查照常让 status=partial + let report = applied_report(facts( + ready_matrix(&UNTRUSTED), + RepoLayout::RepoRoot, + &[("Claude 炼境技能已分发", false)], + Vec::new(), + )); + assert_eq!(report.status, OnboardStatus::Partial); + assert!( + report.summary.contains("Claude 炼境技能已分发"), + "{}", + report.summary + ); + assert!( + report.summary.contains("pending_user"), + "{}", + report.summary + ); + } + + #[test] + fn repo_layout_next_action_explains_why_done_is_unreachable() { + let hooks_missing = [("lefthook hook 挂载", false)]; + let subdir = applied_report(facts( + ready_matrix(&[]), + RepoLayout::Subdirectory { + repo_root: "E:/work/outer".into(), + }, + &hooks_missing, + Vec::new(), + )); + assert_eq!(subdir.status, OnboardStatus::Partial); + let action = subdir + .next_actions + .iter() + .find(|action| action.action == "repo_layout") + .expect("仓库子目录给出 repo_layout"); + for phrase in ["E:/work/outer", "子目录", "lefthook hook 挂载", "done"] { + assert!( + action.detail.contains(phrase), + "缺「{phrase}」:{}", + action.detail + ); + } + assert!(subdir.summary.contains("repo_layout"), "{}", subdir.summary); + + let worktree = applied_report(facts( + ready_matrix(&[]), + RepoLayout::GitFile { + hooks_dir: "E:/work/main/.git/hooks".into(), + }, + &hooks_missing, + Vec::new(), + )); + let action = worktree + .next_actions + .iter() + .find(|action| action.action == "repo_layout") + .expect("worktree 给出 repo_layout"); + for phrase in ["E:/work/main/.git/hooks", "worktree", "lefthook hook 挂载"] { + assert!( + action.detail.contains(phrase), + "缺「{phrase}」:{}", + action.detail + ); + } + + // 仓库根与非 git 目录不给(非 git 由「项目文件改动可报告」未就绪说明) + for layout in [RepoLayout::RepoRoot, RepoLayout::NotGit] { + let report = applied_report(facts(ready_matrix(&[]), layout, &[], Vec::new())); + assert_eq!(count_actions(&report, "repo_layout"), 0); + assert!(!report.summary.contains("repo_layout")); + } + } + + #[test] + fn repo_layout_detects_root_subdirectory_worktree_and_non_git() { + let base = std::env::temp_dir().join(format!("lj_p8e_layout_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let main = base.join("main"); + let plain = base.join("plain"); + std::fs::create_dir_all(main.join("sub")).unwrap(); + std::fs::create_dir_all(&plain).unwrap(); + let git = |dir: &Path, args: &[&str]| { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(dir) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&main, &["init", "-q"]); + std::fs::write(main.join("a.txt"), "a\n").unwrap(); + git(&main, &["add", "-A"]); + git(&main, &["commit", "-q", "--no-verify", "-m", "a"]); + let worktree = base.join("wt"); + git( + &main, + &[ + "worktree", + "add", + "-q", + "--detach", + &worktree.display().to_string(), + ], + ); + + assert_eq!(repo_layout(&plain), RepoLayout::NotGit); + assert_eq!(repo_layout(&main), RepoLayout::RepoRoot); + let normalized = |text: &str| text.replace('\\', "/").to_lowercase(); + match repo_layout(&main.join("sub")) { + RepoLayout::Subdirectory { repo_root } => { + assert!(normalized(&repo_root).ends_with("/main"), "{repo_root}") + } + other => panic!("仓库子目录应判为 Subdirectory:{other:?}"), + } + match repo_layout(&worktree) { + RepoLayout::GitFile { hooks_dir } => assert!( + normalized(&hooks_dir).ends_with("/main/.git/hooks"), + "worktree 的 hook 在主仓库:{hooks_dir}" + ), + other => panic!("worktree 应判为 GitFile:{other:?}"), + } + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 2f3697d..0b581a5 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1730,6 +1730,169 @@ export interface ClientParityL1Report { export const probeClientParity = (projectId: string, tier: "l1" = "l1") => invoke("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("plan_onboarding", { projectId, options }); + +/** 一键接入:须带回用户确认过的计划 digest 与 confirmed=true、同一组 options;首次可能 30 秒以上。结果看 status,refused 等不抛错 */ +export const onboardProject = ( + projectId: string, + expectedDigest: string, + confirmed: boolean, + options?: OnboardOptions, +) => + invoke("onboard_project", { + projectId, + expectedDigest, + confirmed, + options, + }); + // ── Agent Infrastructure ────────────────────────────────────────────────────── export interface DetectedContract {