Compare commits

..

6 Commits

Author SHA1 Message Date
lanrtop
04a0069363 docs(blueprint/agent-client-parity): P8c 标 🔵,补实现、验证、自查与有意偏差(P8c)
All checks were successful
Push & PR Check / check (push) Successful in 1m3s
- 实现:WriteSink/DiskSink、三处分发改经写入汇、onboard/plan.rs 记录汇与计划/digest、
  mcp_inject 本机侧预计动作;合并 master(PR #34)自动合并无冲突。
- 验证:合并后 cargo test 310 passed,补测试后 311 passed / 0 failed / 5 ignored;变异记录。
- 自查:acceptance 逐条对照;有意偏差为计划阶段完全不起 claude CLI(比「假 CLI 只收到 mcp get」更严)、
  文件动作只有 create/update/noop、DiskSink 读失败报错不覆盖。
- 自查发现留待独立审查:非 UTF-8 已存在文件由 skipped 变 failed;bypassPermissions 未在计划单列;
  Codex 信任解析不识别基本字符串转义段头。T1:plan_onboarding 尚无投影,project-context.yaml 由 P8e 补。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:09:04 +09:00
lanrtop
a3d1cc169b test(onboard/parity): 补「计划即实际写入」端到端测试,修正 plan_onboarding 只读注释(P8c 收尾)
- plan_predicts_exactly_what_apply_writes:空仓库先 plan 再经 DiskSink apply,逐文件比对计划内容哈希与
  实际写入,并核对 git status 的文件集合与计划一致。P8d 的 digest 复核依赖这条不变式;
  变异 PlanSink::read 忽略待写内容时,原有 plan 端到端测试仍通过,只有本测试与记录汇单元测试失败。
- mcp_inject::plan_onboarding 注释由「不起子进程」改为「不起客户端 CLI,混合文件预警会起只读 git」,
  与 has_uncommitted_outside_managed 的实际行为一致。

cargo test 311 passed / 0 failed / 5 ignored。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:08:43 +09:00
lanrtop
ea8ed26191 chore(merge): 合并 master(PR #34 重复登记修复 U1/U2)到 P8c 分支
自动合并,无冲突(mcp_inject.rs、commands/onboarding.rs、mcp_tools.rs 双方改动区域不重叠)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:58:15 +09:00
fc280db426 Merge pull request 'fix(mcp-single-server): 删除登记只清本项目托管条目,沉默项目统计去并集,注入状态带项目名(U1、U2)' (#34) from feat/mcp-single-server-u1 into master
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
2026-10-03 08:52:41 +08:00
lanrtop
0e374e5996 docs(tasks): 新增统筹推进文档——统筹会话派卡、执行会话独立执行的协议、环境铁律与派单队列
All checks were successful
Push & PR Check / check (push) Successful in 1m1s
单会话推进易触发上下文压缩、丢失推进所需信息:改为统筹会话维护规划与进度、
每张卡由新会话独立执行并上报。文档含角色与上报/求助/合并协议、踩坑形成的执行
环境铁律、P8 串行派单队列(含可并行的 Gitea 解绑卡)、派单与 PR 流水、待用户
处理事项(重复登记数据合并的执行时机)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:40:59 +09:00
lanrtop
e57da3aebb fix(mcp-single-server): 删除登记只清本项目托管条目,沉默项目统计每条 commit 只归属一个 workspace,注入状态带项目名(U1、U2)
All checks were successful
Push & PR Check / check (push) Successful in 1m34s
Push & PR Check / check (pull_request) Successful in 1m1s
同路径重复登记(dev-manager-tauri 与 2c5b54ef 并存)暴露的三处问题:
- remove_project_files_mcp 按路径解析目录后无条件删 Codex 管理块与 settings 条目,
  删 UUID 那条会连带切断规范 ID 的 Codex 接入;改为只删 URL 属于本 project_id
  或不是炼境项目路由(旧格式)的条目
- silent_projects 的「ID 或路径」任一匹配让一行吃到多条登记的并集(真机 147 / 86),
  改为每条 commit 先按直接 ID、再按 buff 路径归属唯一 workspace
- get_diagnostics 注入状态只打 ID,enterprise-system 的 UUID 被误读成炼境重复登记;
  改为「名称 (`id`)」,已删除的登记标注未登记

Rules-Applied: R06,R07

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:22:25 +09:00
8 changed files with 343 additions and 31 deletions

View File

@ -440,10 +440,10 @@ modules:
- id: mcp-single-server
name: MCP 单一服务器化
area: backend
status: done
progress: 100
updated: 2026-07-06
note: "T1-T6 全部完成并端到端终验(2026-07-06):单一 lian-jing 服务器上线,~/.claude.json 12 条旧注册清零,3 项目抽查新格式通过,旧路由 404。T4 暴露并修复 git-bash 硬编码存量 bug(0d1427f)。遗留:同路径重复登记待清理(见模块 done_note)"
status: in_progress
progress: 89
updated: 2026-10-03
note: "T1-T6 全部完成并端到端终验(2026-07-06):单一 lian-jing 服务器上线,~/.claude.json 12 条旧注册清零,3 项目抽查新格式通过,旧路由 404。T4 暴露并修复 git-bash 硬编码存量 bug(0d1427f)。遗留:同路径重复登记待清理(见模块 done_note)。2026-10-03 开 U1-U3 清理:删除登记只清本项目托管条目、沉默项目统计去并集、2c5b54ef 并入 dev-manager-tauri"
position: [1750, 500]

View File

@ -249,13 +249,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- mcp_verify: "2026-10-03 PR #33 合并(ddd729c)、运行中的炼境重编译生效后:verify_onboarding(enterprise) 的 clients 三端均 converged;连续两次 inject_mcp 回执三端均「已一致(未改动)」,耗时 26 ms、112 ms,.lian-jing/context.json、.codex/config.toml、.claude/settings.json、.claude/liangjing.json 四个文件 mtime 不变,git status 前后均干净。"
- known_limits: "WSL 项目已有的 Claude local 托管条目在端口变更后不再刷新(重注入对 WSL 记 not_applicable),而 remove_project_mcp 与 on_project_root_changed 对 WSL 根仍会起 CLI 清理——口径不完全一致,WSL 路径项目已按 2026-07-04 裁决放弃,暂不处理。Codex 托管块已存在时 upsert 不检查块外手写同名表(存量问题),只读判定会报 needs_human 提醒人工清理。注册表与适配器的端到端组合(缺 CLI 时其余两端照常)只在单元层分别验证,未做真实客户端的组合实测。"
### 📋 P8c — 写入前计划:WriteSink + plan_onboarding
- status: todo
### 🔵 P8c — 写入前计划:WriteSink + plan_onboarding
- status: in_progress
- complexity: M
- files: src-tauri/src/onboard/plan.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/mcp_inject.rs
- depends: P8b
- acceptance: "agent_verify: [cargo test onboard::plan] 临时 git 仓库:空仓库做 plan 全部是 create、目录快照前后字节一致;已接入仓库连续两次 plan 全部 noop、digest 相同,任一目标文件变化 digest 就变;块外有未提交改动时该文件带预警且 plan.blocked 非空;内容相同仅行尾为 CRLF 判 noop;超长 diff 截断并标注;context.json 是别的 project_id 时记 blocked(identity_conflict);machine_changes 列出各本机步骤的预计动作,计划阶段假 claude CLI 只收到 mcp get。[cargo test] 原有 onboarding、blueprint、mcp_inject 测试全部通过、行为不变。"
- 说明:WriteSink 以 write_if_content_changed 作为真实写入汇;P10 的混合文件检测前移到写入之前;与 CI 的 plan/apply 同一模式(Q2)。
- 实现(2026-10-03,分支 feat/agent-client-parity-p8c,提交 ef2c7fd):lib.rs 新增 WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink(真实写入,父目录按需创建)。接入包模板分发(dispatch_templates_with)、蓝图规则同步(sync_blueprint_rules_with)、项目身份与 MCP 投影(write_project_mcp_files_with:context.json、settings.json 兼容条目、liangjing.json、Codex 托管块)全部改经写入汇,原入口改为 DiskSink 薄包装。新增 onboard/plan.rs:PlanSink 只记录不落盘,后续步骤读到前面步骤的待写内容(AGENTS.md 先后被模板分发与蓝图规则改写,计划与实际写入算出同一结果);write_project_files 为 plan 与 apply(P8d)共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff(60 行)、混合文件预警(P10 前移到写入前,记 blocked(mixed_file))、身份冲突、手写 Codex 条目与本机 manual_kept/other_project/needs_human 阻断项,并对(项目 ID、逐文件路径/动作/内容哈希、本机步骤/状态、阻断项)算 sha256 digest。mcp_inject::plan_onboarding 为生产入口:本机侧预计动作 token(读受保护存储判断有无)、shim(比对稳定入口内容)、claude/codex/dsh(复用 P8b 的只读 client_static_status)、codex_trust(只读解析 ~/.codex/config.toml 的 [projects.'<路径>'] trust_level,含祖先目录)。2026-10-03 合并 master(PR #34 重复登记修复 U1/U2,同改 mcp_inject.rs、commands/onboarding.rs、mcp_tools.rs)自动合并无冲突。收尾补测试 plan_predicts_exactly_what_apply_writes(计划的文件集合与内容哈希 = 随后 DiskSink 实际写入,git status 文件集合一致),修正 mcp_inject::plan_onboarding 注释「不起子进程」为「不起客户端 CLI,混合文件预警会起只读 git」。
- verification: "2026-10-03:合并 master 后全量 cargo test 310 passed / 0 failed / 5 ignored(ef2c7fd 时 307,PR #34 带入 3 条);补测试后 311 passed / 0 failed / 5 ignored,编译 0 warning。onboard::plan 7 条:空仓库全 create 且目录快照(含 .git)字节不变;apply 后连续两次计划全 noop、digest 相同,本机动作变化与目标文件变化 digest 都变;CRLF 检出同内容判 noop;混合文件带 warning 且 blocked(mixed_file)、context.json 属别的项目记 blocked(identity_conflict);手写 Codex 条目记 manual_entry、本机 other_project 记 shared_key;PlanSink 覆盖读与改回撤销;diff 截断与标注;计划=实际写入。mcp_inject 新增 codex_trust_reads_projects_table_including_ancestors。变异:PlanSink::read 忽略待写内容 → plan_predicts_exactly_what_apply_writes(AGENTS.md 哈希不符)与 plan_sink_overlays_pending_writes_and_reverts_to_noop 失败,原有 5 条 plan 测试仍通过(补测试前此变异只被单元测试捕获)。"
- 自查(对照 acceptance 逐条):空仓库全 create + 快照字节一致 ✅;两次 plan 全 noop、digest 相同 ✅;任一目标文件变化 digest 变——测试以 capability-packs.json 一个文件为代表,并另测本机动作变化 ✅;块外未提交改动带预警且 blocked 非空 ✅;CRLF 判 noop ✅;超长 diff 截断并标注——在 bounded_diff 单元层测试,未经 plan 端到端构造超长差异 ✅;identity_conflict ✅;machine_changes 列出各本机步骤 ✅(plan_machine_steps 依赖真实凭据存储与用户目录,无注入点,未做单元测试,只经 codex_config_trusts 与 P8b 的只读判定测试间接覆盖);原有 onboarding、blueprint、mcp_inject 测试全部通过 ✅。
- 有意偏差:① 计划阶段完全不起 claude CLI(Claude 只读 ~/.claude.json 判定,沿用 P8b 的 static_status),比 acceptance「假 claude CLI 只收到 mcp get」更严;因此没有假 CLI 断言,由结构保证(static_status 路径无 CLI 调用)。② 文件动作只有 create/update/noop,设计文档 §1 的 append/skip 合并为 update/noop(acceptance 只要求 create/noop)。③ DiskSink 与 write_if_content_changed 同语义但未直接复用:另加按需建父目录,且读失败(非「不存在」)时报错而不是覆盖写。
- 自查发现(留待独立审查处置,本次未改代码):① 行为变化——dispatch_one 原以 target.exists() 判「已存在跳过」,现经 sink.read 判定,已存在但非 UTF-8(如 UTF-16/GBK 的 lefthook.yml、mise.toml)由 skipped 变 failed,plan 中成为 needs_human 阻断;全托管文件读失败由覆盖写变 failed(更安全)。② 设计 §1 要求计划单列 bypassPermissions 默认值(P8 决策 Q4、P8g 视图需要),当前只体现在 .claude/settings.json 的 create 概要里,未单列;本机变更清单也还没有产品组与 buff、.git/hooks(lefthook install)两步——宜在 P8d 编排时补进 machine_changes。③ codex_config_trusts 只认 [projects.'…'] 段头,TOML 基本字符串里的转义反斜杠(如 `[projects."D:\\Work"]`)与内联表写法不识别(本机 Codex 实际写的是字面量字符串小写路径,已核对 13 个段头均为此格式)。
- T1:plan_onboarding 目前无 MCP / Tauri 投影(P8e 才加),输出尚不是外部契约,docs/ai-context/project-context.yaml 未改;P8e 的 acceptance 已含「project-context.yaml 补 plan、digest 与状态语义」。
- 待办:独立只读审查与修复、开 PR(M 级,由统筹会话安排);合并后标 ✅。
### 📋 P8d — onboard_project 编排本体
- status: todo

View File

@ -107,3 +107,31 @@
- depends: T3
- acceptance: "cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中;[HTTP] POST /mcp/group/{gid} → 404(归入 T4 实测)"
- done_note: 2026-07-06 完成。原计划后置一个版本,经评估提前并入本次发版:迁移在新版启动时自动重写全部客户端配置,旧路由的实际消费者只剩升级前的存量会话(服务重启后本就断开),单人环境保留价值趋近于零。tools_call 签名去 Option 化,list_group_projects 改 required 参数
## 遗留清理:同路径重复登记(2026-10-03)
起因:get_diagnostics「使者回传覆盖率」里 dev-manager-tauri 出现两行(147 / 86 条 commit)。只读排查确认是 done_note 记下的遗留——同一路径 `E:\code-project\dev-manager-tauri` 登记了 `dev-manager-tauri`(2026-03-23 批量迁移,现为规范 ID:context.json / buff / 注入 / Codex URL 均指向它)与 `2c5b54ef-…`(2026-04-01 UI 导入)两条 workspace,各自绑一条 gitea_repos 指向同一仓库,推送双采集:commit 按 sha 先到先得被劈成两半(256 / 137),56 对 PR 事件双写,快照双份。诊断里两行数字不同是因为 `silent_projects` 的 `OR win_path` 连接让 UUID 那行吃到了两边并集。「MCP 注入状态」里的 `d248554e-…` 是 enterprise-system,不是重复(该节只打 ID 不打名字导致误读)。
### ✅ U1. 删除登记只清本项目的客户端托管条目
- status: done
- complexity: S
- files: src-tauri/src/mcp_inject.rs
- acceptance: "agent_verify: [cargo test mcp_inject] 同一目录的 Codex 管理块 / .claude/settings.json lian-jing 条目指向别的 project_id 时,remove 后原样保留;指向本 project_id 或不是炼境项目路由(旧格式)时照旧移除"
- done_note: 2026-10-03 完成。新增 `remove_keeps_entries_owned_by_another_project_at_same_root`;`cargo test` 全量 303 通过。运行中的旧二进制尚无此修复,U3 删除登记后须立即 inject_mcp 补回。
- 为什么:`remove_project_files_mcp` 按路径解析目录后无条件删 Codex 管理块与 settings 条目,同路径重复登记时删掉 UUID 那条会顺手切断规范 ID 的 Codex 接入(主仓库 `.codex/config.toml` 的块 URL 是 `/mcp/projects/dev-manager-tauri`)。Claude local 条目(`sync_claude_local`)与 token 本就按 project_id 匹配,不受影响。
### ✅ U2. 沉默项目每个 commit 只归属一个 workspace + 注入状态带项目名
- status: done
- complexity: S
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs
- acceptance: "agent_verify: [cargo test silent_projects] 两个 workspace 同路径、commit 分挂两个 ID 时,每条 commit 只计一次(不再出现一行等于两边并集);[cargo test diagnostics] 注入状态行显示「名称 (`id`)」,已不在登记中的 ID 标注未登记"
- done_note: 2026-10-03 完成。新增 `silent_projects_counts_each_commit_once_under_duplicate_registration`(旧 SQL 在同一夹具上得 [3, 5],新 SQL 得 [2, 3])与 `diagnostics_inject_label_shows_name_and_flags_unregistered`;`cargo test` 全量 303 通过。
- 为什么:`pw.id = cm.project_id OR pw.win_path = bb.project_path` 在同路径多登记时让一条 commit 同时匹配多个 workspace;注入状态只打 ID,人读不出 `d248554e-…` 是哪个项目。
### 🔵 U3. 存量合并:2c5b54ef 并入 dev-manager-tauri 后删除登记
- status: in_progress
- complexity: S
- files: (运维操作,无代码改动)
- acceptance: "agent_verify: [只读 SQL] project_workspaces 中 win_path=E:\code-project\dev-manager-tauri 仅 1 行;gitea_repos 指向 lanrtop/dev-manager-tauri 仅 1 行;commit_metrics/project_notes/pr_events 无 2c5b54ef 残留;[MCP] get_diagnostics 覆盖率 dev-manager-tauri 仅 1 行;verify_onboarding(dev-manager-tauri) 与操作前状态一致;主仓库 git diff .codex/config.toml 为空"
- 步骤:① 先解绑 2c5b54ef 的 Gitea(gitea_unlink_repo,删重复 webhook,停双采集)② 在线备份 DB ③ 单事务迁移:commit_metrics / project_notes / 独有 pr_events / 待办改挂规范 ID,重复 pr_events 与 UUID 快照、文档新鲜度缓存删除,space / repo 绑定 / 产品组成员 / profile 空字段补给规范 ID ④ unregister_project(2c5b54ef) → 立即 inject_mcp(dev-manager-tauri) 补回(运行中的旧二进制尚无 U1 修复)⑤ backfill_notes_archive ⑥ 删空产品组 d669f693
- 不做:「登记时拒绝同路径」归 agent-client-parity P8f(已规划含重复登记拒绝),此处不重复实现;beast-mode 同类重复(beast-mode / 940dfbb4,无 Gitea 绑定、近 30 天无活动)本卡不处理。

View File

@ -0,0 +1,76 @@
# 统筹推进文档:P8 一键接入与并行事项(2026-10-03 起)
> 本文件是执行会话的**单一入口**:领卡前先读完 §1–§3。卡片的范围与验收以蓝图卡片为准
> (`.blueprint/modules/agent-client-parity.md` 等),本文件不复制 acceptance,只管「谁做、按什么顺序、怎么交接」。
> 统筹会话负责维护本文件(§4、§5 随派单与合并更新)。
## 0. 方针与本阶段终点
- **方针**(AGENTS.md「项目定位」「管家准入与退出」):炼境是 agent 开发子项目的管家;每个子项目都能在
Claude Code、Codex、DSH 三端开发,重复配置尽量消除;不过度建设,能力按实测收益保留或撤销。
- **本阶段终点**:P8「一键接入」——新建或添加子项目后,一次确认即三端可用(P8 卡的 acceptance)。
设计:`docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md`;任务卡:agent-client-parity 的 P8-0、P8a–P8g、P4-0、P4a–P4e。
## 1. 角色与协议
| 角色 | 是谁 | 做什么 |
|---|---|---|
| 统筹会话 | `local_ab7498e2-8aff-4e59-b45b-134636d12d9c`(「dev-manager-tauri 开发环境验证」) | 维护本文件、派卡、复核、合并 PR、处理上报;每 30 分钟检查一次执行会话 |
| 执行会话 | 每张卡一个新会话 | 按卡片实现与自验,做一次独立审查,开 PR,然后上报;不合并 PR |
- **上报**:用 SendMessage(`to` 填统筹会话的 session id)发三条——已完成 / 待验证 / 约束继承(AGENTS.md「多 AI CLI 协作」格式),附 PR 编号与**完整 head SHA**。
- **求助**:同一问题自修失败 2 次(AGENTS.md「自验失败降级约定」)就停下,上报失败原因、已尝试路径、需要决策的问题;不要绕过权限或安全分类器的拦截。
- **合并**:只由统筹会话核对 CI 与审查结论后合并(`merge_pull_request` 需要完整 head SHA)。
- **卡片状态**:开工把卡改 🔵、验收后改 ✅(两阶段提交),在自己的分支里改;需要在合并后实测的验收项,由统筹会话合并、实测后补标 ✅。
## 2. 执行环境铁律(都是踩过的坑)
- **主仓库工作区不碰**:`E:\code-project\dev-manager-tauri` 是用户日常运行 `pnpm tauri dev` 的地方。每张卡在自己的 git worktree 里做(如 `E:/code-project/dev-manager-tauri-wt-<卡号>`),M 级开 `feat/<模块>-<卡号>` 分支走 PR;S 级的纯文档可直推 master。
- **enterprise-system 主工作区不碰**:`E:\code-project\enterprise-system` 有其他会话的在制品,禁止 stash / reset / checkout / `add -A`;要改 enterprise 就从 origin/main 开临时 worktree,用 PR 交付。
- **cargo 热缓存**:`cd <worktree>/src-tauri && export CARGO_TARGET_DIR=E:/code-project/dev-manager-tauri-parity/src-tauri/target && cargo test`;长命令放后台运行。
- **rustfmt 只格式化改动的文件**:`rustfmt --edition 2021 --config skip_children=true <文件…>`。不加 `skip_children` 格式化 `lib.rs` 会改写全部子模块。
- **用户 deny 规则**:`rm`、`kill`、`pkill`、`git reset --hard`、npm/npx 等禁用,**也不能写进脚本文件再执行**(统筹会话踩过)。需要删除的临时目录列给用户;探针桩服务设成到时自动退出。
- **Git Bash**:`git show rev:path` 要加 `MSYS_NO_PATHCONV=1`;含反斜杠的文件用 Write 工具写;含中文的 HTTP 请求用 node `fetch`,不用 curl。
- **MCP**:一律 `http://127.0.0.1:27190`(不用 localhost)。会话里 lian-jing 没连上时,可用 node `fetch` 直接 POST `http://127.0.0.1:27190/mcp`(全局路由,`tools/call`),结果在 `result.structuredContent` 或 `result.content[0].text`。
- **变异验证**:先 `git add` 暂存,再改源码做变异、跑测试,最后 `git checkout -- <文件>` 从暂存区还原。没暂存就 checkout 会丢掉改动(统筹会话踩过)。
- **真实客户端**:`claude -p`、`codex exec`、DSH headless 可能跑 3 分钟以上,只在会话主线程或后台 bash 里跑,不交给 Workflow 子 agent(超过 180 秒会被判卡死)。嵌套 claude 前先 `unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_SSE_PORT`,stdin 重定向自 `/dev/null`。
- **凭据**:不读、不打印任何 token、凭据、`.env` 值;不对真实 Windows Credential Manager 做并发压力实验。
- **运行中的炼境 DB 写入**:自动模式下会被安全分类器拦截,这是对的。需要写 DB 的数据修复准备好脚本、备份和空跑结果,交用户执行。
- **提交**:Conventional Commits;`feat:` 带 `Feature-Confirmed: true`;末尾带 `Co-Authored-By` 署名行。PR 描述按自验报告纪律写,含「没验什么」一节。M 级卡开 PR 前用一个只读子代理做独立审查,审查处置写进卡片。
## 3. 派单队列
串行原因:P8 各卡集中改 `mcp_inject.rs`、`mcp_tools.rs`、`commands/onboarding.rs`,并行必然冲突。不碰这些文件的事项可以并行(标注「可并行」)。
| 序 | 事项 | 复杂度 | 起点 | 要点 |
|---|---|---|---|---|
| 1 | **P8c 收尾** | M | 分支 `feat/agent-client-parity-p8c` @ `ef2c7fd`(代码已完成,cargo test 307 passed) | 剩:独立审查与修复,卡片标 🔵 并记实现/验证,开 PR。PR #34 合并后先合并 master 再开 PR |
| 2 | P4a L1 零模型三端检查 | M | master(P4-0✅、P8b✅) | Claude 用不存在的模型名取 init(P4-0 H-P8-4,零额度);Codex 用 `codex mcp get --json`(H-P8-1,未信任目录看不到项目配置);DSH 解析白名单与插件哈希;认证链经稳定 shim 重放 |
| 3 | P8d onboard_project 编排本体 | M | P8c、P4a 合并后 | 复用 `onboard::plan::write_project_files` 与 `onboard::clients::registry()`;apply 前先重算计划并核对 digest |
| 4 | P8e 投影收口与契约同步 | S | P8d 后 | |
| 5 | P8f register_project 控制面投影 | S | P8e 后 | |
| 6 | P8g UI 一键接入 | M | P8e 后 | human_verify 交用户 |
| 并行 | 删除项目登记时一并解绑 Gitea 仓库 | S/M | master | 来自重复登记排查会话的推荐任务卡(轮询会继续往已删除的项目 ID 写数据),主要改 projects.rs / gitea.rs,可并行 |
| 后续 | P6(依赖 P8c)、P4c / P4d / P4e、A4→P4b、P5、beast-mode 重复登记 | | | P8 完成后再排 |
## 4. 派单与 PR 流水(统筹维护)
| 时间 | 事项 | 执行者 | 分支 / PR | 状态 |
|---|---|---|---|---|
| 10-03 | P8-0、P8a、P4-0、B2 | 统筹会话 | 直推 master | ✅ |
| 10-03 | P8b | 统筹会话 | PR #33(ddd729c) | ✅ |
| 10-03 | enterprise 接入包 1.9.0 | 统筹会话 | enterprise PR #183 | ✅ 已合并;enterprise 主工作区待其所有者同步 |
| 10-03 | P8c | 统筹会话(实现)→ 待派执行会话收尾 | `feat/agent-client-parity-p8c` @ ef2c7fd | 待审查 / PR |
| 10-03 | 重复登记修复 U1/U2 | 会话 `local_d2e1c156…`(「排查炼境 dev-manager-tauri 疑似重复登记」) | PR #34(e57da3a,CI passed) | 统筹复核中 |
| 10-03 | 重复登记数据合并 U3 | 同上 | 脚本 `/tmp/merge_dup.py`,已在线备份、空跑通过 | 等用户执行 DB 写入(见 §5) |
## 5. 待用户处理(统筹会话汇总,按时机)
- **重复登记数据合并**:在统筹会话合并 PR #34、主仓库重编译之后,在「排查炼境 dev-manager-tauri 疑似重复登记」会话的输入框执行 `! python /tmp/merge_dup.py --apply`(写运行中的 DB;脚本带前置守卫与事务内断言,已备份)。跑完回复该会话「授权继续」,让它做 `unregister_project` 与验收。
- **Codex 信任**:Codex 只在目录受信任后加载项目 `.codex/config.toml`(P4-0 H-P8-1)。一键接入会把它列进 next_actions,炼境不改 Codex 的信任表。
## 6. 当前已知事实(易变,以工具自省为准)
- `verify_onboarding(enterprise)` 的 `clients` 三端均 converged。
- `list_all_projects`:除 enterprise 外,其余登记项目大多缺项目 token(历史接入早于 token 机制),这些正是一键接入要接的对象。
- 使者回传:0 条待处置。

View File

@ -1191,19 +1191,27 @@ pub fn verify_onboarding(
/// 而是让遗忘可被看见——人看到信号后自行决定是否去该项目跑 /envoy。
/// commit_metrics.project_id 可能是 blueprint_buffs 的 UUID,经 buff 路径
/// 关联回 project_workspaces;未登记项目(无法跑 /envoy)不列入。
/// 每条 commit 只归属一个 workspace(直接 ID 优先,其次 buff 路径):同路径重复登记时
/// 若按「ID 或路径」任一匹配计数,一行会吃到多条登记的并集(2026-10-03 真机 147 / 86)。
pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, String> {
let mut stmt = conn
.prepare(
"SELECT
"WITH recent AS (
SELECT COALESCE(
(SELECT pw.id FROM project_workspaces pw WHERE pw.id = cm.project_id),
(SELECT MIN(pw.id) FROM blueprint_buffs bb
JOIN project_workspaces pw ON pw.win_path = bb.project_path
WHERE bb.project_id = cm.project_id)
) AS ws_id
FROM commit_metrics cm
WHERE cm.committed_at >= datetime('now','-30 days')
)
SELECT
COALESCE(pp.name, pw.id) AS name,
COUNT(*) AS commits
FROM commit_metrics cm
LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id
LEFT JOIN project_workspaces pw
ON pw.id = cm.project_id OR pw.win_path = bb.project_path
FROM recent r
JOIN project_workspaces pw ON pw.id = r.ws_id
LEFT JOIN project_profiles pp ON pw.profile_id = pp.id
WHERE cm.committed_at >= datetime('now','-30 days')
AND pw.id IS NOT NULL
GROUP BY pw.id
HAVING (SELECT COUNT(*) FROM project_notes pn
WHERE pn.project_id = pw.id
@ -2463,4 +2471,33 @@ commit-msg:
let silent = silent_projects(&conn).unwrap();
assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默");
}
#[test]
fn silent_projects_counts_each_commit_once_under_duplicate_registration() {
let conn = crate::db::conn_with_schema();
// 2026-10-03 真机形状:同一路径两条登记,buff 挂规范 ID,commit 按 sha 先到先得分挂两个 ID
conn.execute_batch(
"INSERT INTO project_profiles (id, name) VALUES ('prof-legacy','炼境'),('prof-uuid','炼境');
INSERT INTO project_workspaces (id, profile_id, win_path)
VALUES ('pw-legacy','prof-legacy','E:/dup'),('pw-uuid','prof-uuid','E:/dup');
INSERT INTO blueprint_buffs (project_id, project_path) VALUES ('pw-legacy','E:/dup');
INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
VALUES ('d1','pw-legacy','feat',0,0,datetime('now'),datetime('now')),
('d2','pw-legacy','fix',1,0,datetime('now'),datetime('now')),
('d3','pw-legacy','feat',0,0,datetime('now'),datetime('now')),
('d4','pw-uuid','feat',0,0,datetime('now'),datetime('now')),
('d5','pw-uuid','docs',0,0,datetime('now'),datetime('now'));",
)
.unwrap();
let silent = silent_projects(&conn).unwrap();
let mut counts: Vec<i64> = silent.iter().map(|(_, n)| *n).collect();
counts.sort();
assert_eq!(
counts,
vec![2, 3],
"各计各的,不出现一行等于两边并集:{silent:?}"
);
assert_eq!(counts.iter().sum::<i64>(), 5, "总数等于去重后的 commit 数");
}
}

View File

@ -1536,7 +1536,13 @@ fn upsert_codex_mcp_config_with(
.map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
}
fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
/// 条目 URL 指向别的炼境项目路由:同路径重复登记时,删除一条登记不得连带清掉另一条的接入。
/// 不是炼境项目路由的旧地址(全局 /mcp、组级地址)视为本项目遗留,照旧清理。
fn owned_by_other_project(url: &str, project_id: &str) -> bool {
lianjing_project_of(url).is_some_and(|owner| owner != project_id)
}
fn remove_codex_mcp_config(path: &PathBuf, project_id: &str) -> Result<(), String> {
let current = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
@ -1551,6 +1557,12 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
if end < start {
return Err(format!("{} 的炼境 Codex 管理块不完整", path.display()));
}
let block_url = current[start..end]
.lines()
.find_map(|line| line.trim().strip_prefix("url = \"")?.strip_suffix('"'));
if block_url.is_some_and(|url| owned_by_other_project(url, project_id)) {
return Ok(());
}
let next = format!(
"{}{}",
@ -1689,7 +1701,8 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
/// 一键接入写入前计划(只读:不写文件、不起子进程)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的
/// git status --no-optional-locks 与 git show)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
#[allow(dead_code)]
pub(crate) fn plan_onboarding(
project_id: &str,
@ -2071,20 +2084,30 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
/// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。
fn remove_project_files_mcp(project_id: &str) -> Result<(), String> {
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
let path = settings_path(project_id)?;
if path.exists() {
let mut settings = read_json(&path)?;
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(MCP_KEY);
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(&path, &settings)?;
remove_codex_mcp_config(&codex_config_path(project_id)?, project_id)?;
remove_settings_mcp_entry(&settings_path(project_id)?, project_id)
}
fn remove_settings_mcp_entry(path: &PathBuf, project_id: &str) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
Ok(())
let mut settings = read_json(path)?;
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
let foreign = servers
.get(MCP_KEY)
.and_then(|entry| entry.get("url"))
.and_then(Value::as_str)
.is_some_and(|url| owned_by_other_project(url, project_id));
if !foreign {
servers.remove(MCP_KEY);
}
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(path, &settings)
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
@ -2393,7 +2416,7 @@ mod tests {
assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\""));
assert!(!migrated.contains("url = \"http://127.0.0.1:27190/mcp\"\n"));
remove_codex_mcp_config(&path).unwrap();
remove_codex_mcp_config(&path, "project one").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"model = \"gpt-6\"\n"
@ -2414,6 +2437,81 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
/// 2026-10-03 真机:同一目录登记了 dev-manager-tauri 与 2c5b54ef 两条,删 UUID 那条会把
/// 指向规范 ID 的 Codex 管理块一并删掉。删除只清 URL 属于本项目(或旧格式)的条目。
#[test]
fn remove_keeps_entries_owned_by_another_project_at_same_root() {
let base = std::env::temp_dir().join(format!(
"lianjing_remove_scoped_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
std::fs::create_dir_all(base.join(".claude")).unwrap();
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
let codex = base.join(".codex/config.toml");
let other_url = "http://127.0.0.1:27190/mcp/projects/canonical-id";
std::fs::write(&codex, "model = \"gpt-6\"\n").unwrap();
upsert_codex_mcp_config(&codex, other_url, helper).unwrap();
let before = std::fs::read_to_string(&codex).unwrap();
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
before,
"别的项目的 Codex 管理块必须原样保留"
);
remove_codex_mcp_config(&codex, "canonical-id").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
"model = \"gpt-6\"\n"
);
// 旧格式全局地址不属于任何项目路由,视为遗留照旧清理
upsert_codex_mcp_config(&codex, "http://127.0.0.1:27190/mcp", helper).unwrap();
remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap();
assert_eq!(
std::fs::read_to_string(&codex).unwrap(),
"model = \"gpt-6\"\n"
);
let settings = base.join(".claude/settings.json");
let write_settings = |url: &str| {
std::fs::write(
&settings,
json!({ "mcpServers": {
MCP_KEY: { "type": "http", "url": url },
"lian-jing:group-old": { "type": "http", "url": "http://127.0.0.1:27190/mcp/group/g" },
"other": { "command": "x" }
} })
.to_string(),
)
.unwrap();
};
let servers = || read_json(&settings).unwrap()["mcpServers"].clone();
write_settings(other_url);
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
let kept = servers();
assert_eq!(
kept[MCP_KEY]["url"], other_url,
"别的项目的 settings 条目必须保留"
);
assert!(
kept.get("lian-jing:group-old").is_none(),
"旧组级 key 照旧清理"
);
assert!(kept.get("other").is_some());
write_settings("http://127.0.0.1:27190/mcp/projects/duplicate-uuid");
remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap();
let removed = servers();
assert!(removed.get(MCP_KEY).is_none());
assert!(removed.get("other").is_some());
let _ = std::fs::remove_dir_all(&base);
}
// ── P8b:三端结构化结果 ──
fn claude_get_local(url: &str) -> String {

View File

@ -1431,6 +1431,22 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
// ── 工具实现 ──────────────────────────────────────────────────────────────────
/// 诊断里的项目标签「名称 (`id`)」:只打 ID 人读不出是哪个项目(2026-10-03 把 enterprise-system 的
/// UUID 误读成炼境重复登记);日志里残留的已删除登记标注未登记,免得当成现存项目。
fn diagnostics_project_label(conn: &rusqlite::Connection, project_id: &str) -> String {
match conn.query_row(
"SELECT pp.name FROM project_workspaces pw
LEFT JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pw.id = ?1",
[project_id],
|r| r.get::<_, Option<String>>(0),
) {
Ok(Some(name)) => format!("{name} (`{project_id}`)"),
Err(rusqlite::Error::QueryReturnedNoRows) => format!("`{project_id}`(未登记)"),
_ => format!("`{project_id}`"),
}
}
fn get_diagnostics() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let port = crate::mcp_server::read_port();
@ -1494,8 +1510,11 @@ fn get_diagnostics() -> Result<String, String> {
.unwrap_or_else(|| "unknown".to_string());
let icon = if level == "info" { "✅" } else { "⚠️" };
out.push_str(&format!(
"- {} `{}` — {} _({})\n",
icon, project_id, msg, ts
"- {} {} — {} _({})\n",
icon,
diagnostics_project_label(&conn, &project_id),
msg,
ts
));
}
out.push('\n');
@ -3170,4 +3189,19 @@ mod tests {
"apply_onboarding_pack 的 project_id 应可省略(U1 批量模式)"
);
}
#[test]
fn diagnostics_inject_label_shows_name_and_flags_unregistered() {
let conn = crate::db::conn_with_schema();
conn.execute_batch(
"INSERT INTO project_profiles (id, name) VALUES ('prof-e','enterprise-system');
INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('uuid-e','prof-e','E:/e');",
)
.unwrap();
assert_eq!(
diagnostics_project_label(&conn, "uuid-e"),
"enterprise-system (`uuid-e`)"
);
assert_eq!(diagnostics_project_label(&conn, "gone"), "`gone`(未登记)");
}
}

View File

@ -443,6 +443,38 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// 「看到的就是将写的」:计划列出的文件集合与内容哈希,和随后 apply 实际写入的完全一致
/// (P8d 的 digest 复核依赖这条不变式)。
#[test]
fn plan_predicts_exactly_what_apply_writes() {
let root = temp_repo("predict");
let plan = plan_onboarding(&root, &PROJECT, machine("apply"));
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
for file in &plan.files {
let written = std::fs::read_to_string(root.join(&file.path)).unwrap();
assert_eq!(
sha256_hex(&written.replace("\r\n", "\n")),
file.content_sha256,
"{} 计划内容与实际写入不一致",
file.path
);
}
let status = std::process::Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(&root)
.output()
.expect("git 可执行");
let mut changed: Vec<String> = String::from_utf8_lossy(&status.stdout)
.lines()
.map(|line| line[3..].trim_matches('"').to_string())
.collect();
changed.sort();
let mut planned: Vec<String> = plan.files.iter().map(|f| f.path.clone()).collect();
planned.sort();
assert_eq!(changed, planned, "apply 写入的文件集合与计划一致");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn plan_after_apply_is_noop_and_digest_tracks_changes() {
let root = temp_repo("applied");