feat: add BlueprintPromptModal for blueprint management and sync functionality

- Implemented BlueprintPromptModal component to handle blueprint prompt generation and rule synchronization.
- Added BlueprintStatusBadge to display the current status of blueprints in ProjectCard.
- Integrated blueprint status fetching and synchronization in ProjectCard.
- Enhanced SettingsPage with a new section for MCP service configuration and batch sync for blueprint rules.
- Updated commands to include new blueprint-related functionalities and types.
- Changed application name from "Dev Manager" to "炼境" in various components.
This commit is contained in:
lanrtop 2026-04-04 10:47:27 +09:00
parent a2596413c5
commit c598c9b05b
34 changed files with 3669 additions and 142 deletions

View File

@ -1,5 +1,5 @@
---
version: 1.0.0
version: 1.3.0
updated: 2026-04-03
source: dev-manager-tauri
---
@ -25,10 +25,12 @@ source: dev-manager-tauri
| 事件 | 操作 |
|------|------|
| 用户确认了新需求 | 在 manifest.yaml 新增模块,创建 modules/<id>.md |
| 完成了功能实现 | 更新模块 status → done标记任务卡 ✅ |
| **开始执行任务卡信息1** | **任务卡前缀改为 🔵status → in_progress第一行代码之前** |
| **完成了功能实现信息2** | **任务卡前缀改为 ✅status → done验收通过之后** |
| 拆分了大需求为子任务 | 在 modules/<id>.md 中添加任务卡 |
| 调整了架构方向 | 更新 edges 关系,可能调整 area |
| 放弃了某个方向 | 删除对应模块和文件,或标记 status: abandoned |
| **重构已完成的模块(迭代)** | **manifest.iteration 递增,旧任务卡保留,新建替代任务卡,模块加 `## 迭代记录`** |
## 模块状态定义
@ -52,6 +54,8 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
- depends: other-module-id
- acceptance: 简要描述完成标准
- blocked_reason: (仅 status: blocked 时填写)执行受阻的具体原因
- iteration: 1 # 可选,省略时继承 manifest 顶层 iteration 值
- replaces: 旧任务标题 # 可选,仅重构任务填写,说明本卡替代哪张旧卡
详细说明(可选)。
```
@ -60,8 +64,8 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
| 前缀 | status | 含义 |
|------|--------|------|
| ✅ | done | 已完成 |
| 🔵 | in_progress | 进行中 |
| ✅ | done | 已完成信息1 + 信息2 均已写入) |
| 🔵 | in_progress | 执行中或被中断只有信息1等待信息2 |
| 📋 | todo | 可派发(信息充足) |
| 💭 | concept | 构思中 |
| 🔴 | blocked | 执行受阻 |
@ -78,6 +82,15 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
## manifest.yaml 规范
### 顶层字段
```yaml
version: 1
name: 项目名称
iteration: 1 # 当前迭代号(默认 1重构已完成模块时手动递增
updated: YYYY-MM-DD
```
### 模块字段
```yaml
@ -109,7 +122,64 @@ modules/<id>.md 中的每个二级标题(`###`)是一张任务卡:
模块的 progress 由其任务卡的完成比例推算:
- 无任务卡的模块:手动设置 progress
- 有任务卡的模块:`完成数 / 总数 × 100`(取整)
- 有任务卡的模块(单迭代):`完成数 / 总数 × 100`(取整)
- 有任务卡的模块(多迭代):**只计当前 iteration 的任务卡**,旧迭代 done 任务不计入分母,避免历史完成率虚高
## 迭代管理
### 何时递增 iteration
**需要递增**(重构已完成的功能):
- 一个已有 ✅ done 任务卡的模块需要重新设计或重写
- 核心交互/架构方案被替换(不是在原有基础上扩展)
**不需要递增**(正常演进):
- 在现有模块上添加新功能
- 修复 bug
- 新增模块
### 重构时的操作步骤
1. **manifest.yaml 顶层** `iteration` +1更新 `updated` 日期
2. **旧任务卡**:保留原位,在卡片末尾加一行注释说明被替代(不改 status、不删除
```markdown
### ✅ 旧功能实现
- status: done
- iteration: 1
...
> ⚠️ 已被迭代 2「新功能实现」替代仅作历史记录。
```
3. **新任务卡**:新建替代卡,标注 `iteration``replaces`
```markdown
### 📋 新功能实现
- status: todo
- iteration: 2
- replaces: 旧功能实现
- complexity: M
- files: ...
- acceptance: ...
```
4. **模块文件**:追加 `## 迭代记录` 段落(见下方格式)
5. **模块 status**:重置为 `in_progress`progress 按当前迭代任务卡重新计算
### 迭代记录格式
模块文件末尾追加,每次迭代一条:
```markdown
## 迭代记录
### 迭代 2YYYY-MM-DD
- **原因**React Flow 在 50+ 节点时性能卡顿,重构为虚拟化渲染方案
- **替代**「React Flow 节点图渲染迭代1」→「节点图虚拟化渲染」
- **保留**迭代1的任务卡作为历史记录不删除
```
### 梦核如何感知迭代
- usage.json 快照携带 `iteration` 字段(见飞轮外环章节)
- 飞轮数据层按 iteration 分组计算完成率,迭代跳变(如 1→2 后完成率骤降)是正常信号
- 梦核 prompt 会展示「完成率跳变项目」摘要Opus 据此判断是主动重构还是项目失控
## 决策记录格式
@ -144,15 +214,32 @@ Claude 在每次对话中应遵循以下流程:
| "开始做 XX" | 模块 status → in_progress |
| "为什么选这个方案" | 记录到模块的 `## 决策记录` |
### 阶段 3实现功能 — 标记完成或上报阻塞
```
成功 → 更新任务卡前缀为 ✅ → status: done
所有任务卡完成 → 模块 status: done
更新 manifest.yaml 的 updated 日期
### 阶段 3实现功能 — 两阶段执行锁
任务卡执行遵循**两阶段提交**开始写第一行代码前加锁信息1全部完成后解锁信息2。只有信息1没有信息2说明任务被中断。
失败且可自行解决 → 继续尝试
失败且无法独立解决 → 标记 🔴 blocked + 填写 blocked_reason → 留给架构师处理
```
📋 todo
↓ 【信息1加锁】第一行代码改动之前先更新任务卡
🔵 in_progress
↓ 执行代码改动
├─ 成功 → 【信息2解锁】更新任务卡前缀为 ✅ → status: done
│ 所有任务卡完成 → 模块 status: done
│ 更新 manifest.yaml 的 updated 日期
├─ 失败且可自行解决 → 继续尝试,保持 🔵
└─ 失败且无法独立解决 → 🔴 blocked + blocked_reason → 留给架构师处理
```
#### 锁悬空时的续接规则
新会话读蓝图发现 🔵 任务卡只有信息1没有信息2
1. 检查任务卡的 `files` 字段,读取相关文件的**当前代码状态**
2. 判断已完成什么、还缺什么
3. 从断点继续执行,**不重头来过**
4. 完成后补写 ✅信息2关锁
#### 架构师处理 blocked 任务
当 Opus 读蓝图发现 🔴 blocked 任务时:
@ -179,6 +266,74 @@ Claude 应关注这些信号来决定是否更新蓝图:
- 执行模型遇到无法解决的问题 → 标记 🔴 blocked + blocked_reason
- Opus 看到 blocked 任务 → 分析原因,拆/补/调后产出新任务卡
## 飞轮外环(跨项目数据反哺)
内环描述的是单项目的执行流程。外环负责**把多个项目的执行数据聚合起来,反哺 CONVENTIONS 本身**。
### 数据采集usage.json 快照
每次 Claude 读取 `.blueprint/manifest.yaml`(通过炼境打开蓝图时触发),炼境自动向项目的 `.blueprint/usage.json` 追加一条当日快照:
```json
{
"project_name": "my-project",
"snapshots": [
{
"at": "2026-04-05T10:00:00Z",
"conventions_version": "1.3.0",
"iteration": 1,
"modules": { "total": 22, "done": 18, "in_progress": 2, "planned": 1, "concept": 1, "blocked": 0, "stalled": [] },
"tasks": { "total": 45, "done": 38, "in_progress": 3, "blocked": 0, "todo": 4, "dispatched": 3 },
"blocked_reasons": []
}
]
}
```
- `dispatched` = 当前满足📋可派发条件的任务数(有 files + acceptance + complexity S/M
- 节流:同一 UTC 日期只写一条,避免多次打开导致数据膨胀
- 文件存于项目本地,不上传服务器
### 数据聚合:飞轮指标
炼境跨项目读取所有 usage.json`conventions_version` 分组,对每个版本队列计算 4 项指标:
| 指标 | 计算方式 | 含义 |
|------|----------|------|
| 可派发率 | dispatched / total tasks | 任务卡规划质量,越高说明任务定义越清晰 |
| 任务完成率 | done / total tasks | 整体执行进度均值 |
| 飞轮持续率 | 30天内有快照的项目比例 | 外环是否在持续运转 |
| 阻塞密度 | blocked / total tasks | 系统性阻塞程度,越低越好 |
迭代感知:同一项目若 `iteration` 值增大,说明发生了主动重构。飞轮数据层对此类项目单独计算「当前迭代完成率」,与历史累计值区分展示,避免虚高。梦核 prompt 中标注「完成率跳变项目」供 Opus 分析是健康重构还是项目失控。
### 梦核分析:记忆巩固
炼境将聚合数据 + 当前 CONVENTIONS 全文格式化为提示词,发给 **Claude Opus** 做两件事:
- **固化**:哪些跨项目重复出现的阻塞模式、哪些有效实践,值得写进 CONVENTIONS
- **遗忘**哪些休眠项目90天无快照的 AI 文档可以归档,哪些 CONVENTIONS 规则已过时
### 闭环路径
```
执行任务 → usage.json 快照
炼境聚合指标
梦核提示词 → Claude Opus 分析
用户/维护者审核建议
更新 CONVENTIONS.md 源码 + 版本号
发布新版炼境
治理面板 sync_blueprint_rules → 分发到各项目
```
> **注意**CONVENTIONS.md 编译进炼境二进制,更新需要修改炼境源码并发版,不支持运行时修改。这是有意为之的设计——保证规则版本可追溯,防止随意污染。
## 注意事项
- 不要删除已完成的任务卡,它们是项目历史的一部分

View File

@ -1,7 +1,9 @@
version: 1
name: Dev Manager Tauri
name: 炼境
description: Git 多空间开发管理器集成项目管理、Git 操作、CI/CD、开发工具链管理
updated: 2026-04-03
iteration: 1
updated: 2026-04-04
areas:
@ -83,8 +85,8 @@ modules:
- id: blueprint-onboarding
name: 蓝图接入引导
area: frontend
status: planned
progress: 0
status: done
progress: 100
position: [1250, 0]
# ── 后端 ────────────────────────────────────────
@ -140,15 +142,22 @@ modules:
- id: blueprint-governance
name: 蓝图治理
area: backend
status: planned
progress: 0
status: done
progress: 100
position: [1250, 350]
- id: blueprint-feedback
name: 蓝图反馈聚合
- id: blueprint-flywheel
name: 飞轮数据层
area: backend
status: concept
progress: 0
status: done
progress: 100
position: [1000, 500]
- id: blueprint-feedback
name: 梦核 · 记忆巩固
area: backend
status: done
progress: 100
position: [1250, 500]
# ── 基础设施 ────────────────────────────────────
@ -163,7 +172,7 @@ modules:
name: 客户端自动更新
area: infra
status: done
progress: 90
progress: 100
position: [250, 650]
- id: wsl-proxy
@ -187,6 +196,34 @@ modules:
progress: 100
position: [750, 650]
- id: tray-service
name: 系统托盘常驻
area: infra
status: done
progress: 100
position: [1000, 650]
- id: mcp-server
name: 内嵌 MCP Server
area: backend
status: done
progress: 100
position: [1500, 350]
- id: mcp-config-inject
name: MCP 配置注入
area: backend
status: done
progress: 100
position: [1500, 500]
- id: product-group-mcp
name: 产品组 MCP 设置
area: frontend
status: done
progress: 100
position: [1500, 0]
edges:
# 前端依赖
- from: workspace-mgmt
@ -235,14 +272,21 @@ edges:
- from: blueprint-reader
to: blueprint-governance
type: related
- from: blueprint-view
to: blueprint-onboarding
type: dependency
- from: blueprint-governance
to: blueprint-feedback
type: related
- from: blueprint-onboarding
to: blueprint-view
type: dependency
# 外环:飞轮数据层 → 梦核 → 治理分发
- from: blueprint-reader
to: blueprint-flywheel
type: dependency
- from: blueprint-flywheel
to: blueprint-feedback
type: dependency
- from: blueprint-feedback
to: blueprint-governance
type: dependency
- from: blueprint-view
to: blueprint-flywheel
type: related
# 基础设施关联
@ -258,3 +302,17 @@ edges:
- from: auto-update
to: settings-page
type: related
# 产品组 MCP 飞轮
- from: tray-service
to: mcp-server
type: dependency
- from: mcp-server
to: mcp-config-inject
type: dependency
- from: mcp-config-inject
to: product-group-mcp
type: dependency
- from: product-group-mcp
to: settings-page
type: related

View File

@ -2,7 +2,7 @@
id: auto-update
name: 客户端自动更新
status: done
progress: 90
progress: 100
---
## 概述
@ -35,6 +35,6 @@ progress: 90
updater:default + process:default + http 域名放行。
### 💭 自动检查更新
- status: concept
### 自动检查更新
- status: done
- notes: 应用启动时自动检查,发现新版本后在状态栏提示,而非需要手动进入设置页。

View File

@ -1,29 +1,64 @@
---
id: blueprint-feedback
name: 蓝图反馈聚合
status: concept
progress: 0
name: 梦核 · 记忆巩固
status: done
progress: 100
---
## 概述
建立跨项目蓝图反馈收集和分析机制。各项目 Claude 执行时将遇到的问题、格式缺口、改进建议写入 `.blueprint/FEEDBACK.md`dev-manager-tauri 扫描聚合后呈现,供 Opus 分析并优化 master CONVENTIONS.md形成飞轮闭环
炼境的记忆巩固层。类比人类睡眠时大脑对记忆的主动处理——保留重要的(固化进 CONVENTIONS清理无用的归档休眠文档
## 决策记录
梦核不是算法是一条精心设计的提示词。炼境负责收集和格式化数据飞轮数据层Claude 负责做判断:哪些跨项目重复出现的阻塞模式值得写进 CONVENTIONS哪些长期休眠的文档可以归档。用户确认后治理模块执行分发。
- FEEDBACK.md 为纯 Markdown按条目写入每条含 type / reason / suggestion 字段
- 反馈类型blocked执行受阻/ convention_gap规则不够用/ format_issue格式问题/ improvement改进建议
- 聚合视图只读不提供编辑功能Claude 才是修改 CONVENTIONS.md 的执行者
```
[飞轮数据层] 聚合跨项目数据
[梦核提示词] 喂给 Claude Opus 做判断
固化 ──────────────────── 遗忘
↓ ↓
CONVENTIONS 改进草稿 归档候选列表
↓ ↓
[治理模块] 分发 用户确认 → 归档操作
```
## 任务卡
### 💭 FEEDBACK.md 格式约定
- status: concept
- notes: 需要在 CONVENTIONS.md 中加入 FEEDBACK.md 的写入规则和格式规范,确保各项目 Claude 写入格式一致
### ✅ 梦核分析入口(蓝图弹窗)
- status: done
- complexity: S
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
- depends: blueprint-flywheel
- acceptance: 治理面板新增「梦核分析」区块,点击"♻️ 打开飞轮面板"跳转到飞轮面板;飞轮面板生成梦核提示词(可复制);引导文字说明正确流程:「复制 → 粘贴给 Claude Opus → 将建议反馈给炼境维护者 → 更新 CONVENTIONS.md 并发布新版本」(梦核不自动写入,需手动修改源码发版)
### 💭 get_all_blueprint_feedbacks command
- status: concept
- notes: 扫描所有已注册项目的 .blueprint/FEEDBACK.md解析条目按 type 分类返回
### ✅ 归档候选展示
- status: done
- complexity: S
- files: src/components/dashboard/BlueprintModal.tsx
- depends: blueprint-flywheel
- acceptance: 飞轮面板展示两类归档候选:① 飞轮停转项目usage.json 最新 at 距今超过 60 天,显示最后活跃日期)② AI文档休眠项目usage.json 最新 at 距今超过 90 天,推断 Claude 长期未读该项目的 AI 文档);每条候选显示项目名 + 最后活跃日期 + 「归档」按钮
### 💭 跨项目反馈聚合视图
### ✅ 归档 command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: archive_project_docs command接收项目路径将 .blueprint/modules/*.md 复制到 .blueprint/archive/YYYY-MM-DD-HHmmss/ 后删除原文件;写 MANIFEST.txt 记录归档信息返回归档摘要archived_files + archive_dir
### 💭 恢复 command
- status: concept
- notes: 在蓝图界面或设置页展示:高频 blocked 原因、重复出现的 convention_gap、跨项目 suggestion 列表;提供「复制给 Opus 分析」按钮,生成汇总报告
- notes: 读取 .blueprint/archive/*/MANIFEST.txt将文件还原到原路径支持选择性恢复覆盖前需用户确认
### ✅ 梦核提示词增加停滞模块数据
- status: done
- complexity: S
- files: src-tauri/src/commands/blueprint.rs, src/lib/commands.ts
- acceptance: get_flywheel_stats 收集所有项目最新快照的 modules.stalled聚合为 stalled_modules: Vec<String>(格式 "project/module_id"FlywheelStats 新增 stalled_modules 字段梦核提示词新增「停滞模块」摘要段同步实现迭代跳变检测iteration_jumps并补入提示词
## 决策记录
- 梦核的核心是提示词工程而非算法,判断质量随 Claude 模型升级自动变强,无需维护打分公式
- 固化输出CONVENTIONS 改进)由用户确认后交 governance 执行,不自动写入,保留人工节点
- 遗忘操作归档提供撤销能力MANIFEST.txt + 恢复 command降低误操作风险
- AI 文档休眠判断基于 usage.json 中 get_blueprint 的调用记录,间接反映 Claude 是否在读这些文档
- 命名选「梦核」:梦境处理记忆(非主动学习),核心是巩固而非监控
- 归档功能直接操作 .blueprint/modules/,用户在飞轮面板触发,清空休眠项目的模块 md 文件

View File

@ -0,0 +1,55 @@
---
id: blueprint-flywheel
name: 飞轮数据层
status: done
progress: 100
---
## 概述
外环数据采集与聚合层。在各项目的 .blueprint/usage.json 中记录飞轮运转快照任务完成率、阻塞密度、conventions 版本),炼境跨项目聚合后计算健康指标,并格式化为梦核分析提示词的输入数据。
代码只处理确定性的机械工作(采集、计算、格式化),判断交给 Claude梦核
## 任务卡
### ✅ usage.json 埋点写入
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs
- acceptance: get_blueprint command 执行后,向项目 .blueprint/usage.json 追加一条 snapshotschema{ project_name, at, conventions_version, iteration, modules: {total,done,in_progress,planned,concept,blocked,stalled:string[]}, tasks: {total,done,in_progress,blocked,todo,dispatched}, blocked_reasons: string[] }iteration 从 manifest.yaml 顶层 iteration 字段读取,失败写 1节流同一 UTC 日期同一项目只追加一条conventions_version 从 .blueprint/CONVENTIONS.md frontmatter 读取,失败写 "unknown"usage.json 不存在时自动创建为 { "project_name": "...", "snapshots": [] }
### ✅ get_flywheel_stats command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- depends: blueprint-reader
- acceptance: 接收项目路径数组(由前端从 store 传入,与 sync_blueprint_rules 同模式);读取每个项目的 .blueprint/usage.json按 conventions_version 分组,对每组计算 4 项指标可派发率dispatched/total tasks、任务完成率done/total tasks、飞轮持续率30天内有快照的项目比例、阻塞密度blocked/total tasks跨项目聚合 blocked_reasons TOP10检测停转项目60天无快照和 AI文档休眠项目90天无快照均携带 last_active 日期;返回结构化 FlywheelStats
### ✅ get_flywheel_stats 支持迭代感知
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src/lib/commands.ts
- acceptance: usage.json snapshot 写入 iteration读 manifest.yaml 顶层字段,失败写 1BlueprintManifest struct 新增 iteration 字段get_flywheel_stats 扫描每个项目所有快照,检测 iteration 跳变,返回 iteration_jumps: Vec<IterationJump>;同时收集最新快照中 modules.stalled 列表,返回 stalled_modules: Vec<String>(格式 "project/module_id");梦核 prompt 补充「迭代跳变项目」和「停滞模块」两段摘要commands.ts 新增 IterationJump 接口FlywheelStats 补两字段
### ✅ 梦核提示词生成 command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- depends: blueprint-reader
- acceptance: generate_muhe_prompt command接收 FlywheelStats由前端先调 get_flywheel_stats 获取后传入)+ 读取 master CONVENTIONS.md 内容生成完整梦核分析提示词字符串提示词包含聚合数据摘要、TOP blocked_reasons、CONVENTIONS 全文、固化/遗忘双任务指令;注意:本任务须在 get_flywheel_stats 实现后执行
### ✅ 飞轮健康面板
- status: done
- complexity: M
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
- depends: blueprint-view
- acceptance: 蓝图弹窗新增「飞轮」侧边面板;展示:各 conventions 版本队列的指标对比表(可派发率 / 任务完成率 / 飞轮持续率 / 阻塞密度)、跨项目 blocked_reasons TOP10、飞轮停转项目6090天仅展示未进入休眠列表的、AI文档休眠项目90天+,含归档按钮+二次确认弹窗、迭代跳变项目iteration 递增展示完成率跳变标注大幅下降、停滞模块in_progress 但无可推进任务,格式 project/module_id「生成梦核提示词」按钮调用 generate_muhe_prompt展示结果供复制
## 决策记录
- 代码只做机械工作(采集、聚合、格式化),梦核判断完全交给 Claude不写打分公式
- usage.json 存各项目本地,不上传服务器,符合炼境 document-driven 设计哲学
- 节流策略:同一天一条,避免频繁打开蓝图导致数据膨胀
- conventions_version 打标是版本对比的关键,是外环版本评估的基础
- 4项指标中「可派发率」衡量任务卡规划质量「飞轮持续率」衡量外环是否在转两者是最先导指标任务完成率衡量整体执行进度阻塞密度是系统性风险信号
- iteration 字段用于区分历史 done 和当前迭代 done避免多次重构后完成率虚高迭代跳变是正常信号主动重构梦核据此判断而非告警

View File

@ -1,8 +1,8 @@
---
id: blueprint-governance
name: 蓝图治理
status: planned
progress: 0
status: done
progress: 100
---
## 概述
@ -13,36 +13,37 @@ progress: 0
- CONVENTIONS.md 加 YAML frontmatter 版本号,应用以此判断是否需要同步
- CLAUDE.md 用 `<!-- BLUEPRINT_MANAGED_START -->` / `<!-- BLUEPRINT_MANAGED_END -->` 标记管理区,应用只替换标记内内容,不动项目自有内容
- 无 CLAUDE.md 时生成初始文件;有 CLAUDE.md 但无 MANAGED 区时追加到末尾
- MASTER_CONVENTIONS 通过 `include_str!()` 在编译时嵌入,保证 binary 自包含
- BlueprintStatus 四状态none / rules_outdated / content_stale / synced
## 任务卡
### 📋 CONVENTIONS.md 版本化
- status: todo
### CONVENTIONS.md 版本化
- status: done
- complexity: S
- files: .blueprint/CONVENTIONS.md
- acceptance: frontmatter 加 version 和 updated 字段,格式为 semver如 1.0.0
### 📋 get_blueprint_status command
- status: todo
- complexity: M
- files: src-tauri/src/commands/blueprint.rs
- acceptance: 接收项目路径返回四种状态之一none / rules_outdated / content_stale / synced判断依据.blueprint/ 是否存在、CONVENTIONS.md 版本是否匹配 master、CLAUDE.md 是否有 MANAGED 区且版本匹配
### 📋 sync_blueprint_rules command
- status: todo
- complexity: M
- files: src-tauri/src/commands/blueprint.rs
- acceptance: 接收项目路径,执行三步:(1) 覆盖写入最新 CONVENTIONS.md(2) 检测 CLAUDE.md 是否存在及 MANAGED 区状态,按三种情况(无文件/无区块/版本旧)分别处理;(3) 返回操作摘要
### 📋 CLAUDE.md 模板定义
- status: todo
### ✅ CLAUDE.md 模板定义
- status: done
- complexity: S
- files: src-tauri/src/commands/blueprint.rs
- acceptance: 在 Rust 中定义 CLAUDE.md MANAGED 区的标准内容模板,内容涵盖:蓝图飞轮流程、读取 manifest 指令、任务卡派发规则;模板版本与 CONVENTIONS.md 同步
### 📋 批量同步所有项目规则
- status: todo
### ✅ get_blueprint_status command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: 接收项目路径返回四种状态之一none / rules_outdated / content_stale / synced判断依据.blueprint/ 是否存在、CONVENTIONS.md 版本是否匹配 master、CLAUDE.md 是否有 MANAGED 区且版本匹配
### ✅ sync_blueprint_rules command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: 接收项目路径,执行三步:(1) 覆盖写入最新 CONVENTIONS.md(2) 检测 CLAUDE.md 是否存在及 MANAGED 区状态,按三种情况(无文件/无区块/版本旧)分别处理;(3) 返回操作摘要
### ✅ 批量同步所有项目规则
- status: done
- complexity: S
- files: src-tauri/src/commands/blueprint.rs, src/components/settings/SettingsPage.tsx
- depends: sync_blueprint_rules command
- files: src/components/settings/SettingsPage.tsx
- acceptance: 设置页提供「同步所有项目蓝图规则」按钮,调用 sync_blueprint_rules 遍历所有已注册项目,返回每个项目的同步结果摘要

View File

@ -1,8 +1,8 @@
---
id: blueprint-onboarding
name: 蓝图接入引导
status: planned
progress: 0
status: done
progress: 100
---
## 概述
@ -13,26 +13,26 @@ progress: 0
- 提示词生成由 Rust 完成(扫描目录结构、读取 git log、拼接模板前端只做展示
- 提示词包含完整上下文(项目信息 + 目录结构 + 现有蓝图 + CONVENTIONS 摘要),确保 Claude 无需额外询问
- 目录扫描限深度 3 层,避免输出过大
- 配置文件扫描采用「存在则读」策略AI 文档ARCHITECTURE.md/CLAUDE.md/AGENTS.md自动包含对无此类文件的简单项目零影响
- monorepo 自动感知:检测到 pnpm-workspace.yaml 等标志文件后,自动读子包配置并调整模块数建议,用户无需手动区分项目复杂度
## 任务卡
### 📋 generate_blueprint_prompt command
- status: todo
### generate_blueprint_prompt command
- status: done
- complexity: M
- files: src-tauri/src/commands/blueprint.rs
- depends: blueprint-governance
- acceptance: 接收项目路径和提示词类型init / sync返回对应提示词字符串init 类型扫描目录结构 + 读取 README/package.json/Cargo.tomlsync 类型额外读取现有蓝图文件 + 近 20 条 git log
- acceptance: 接收项目路径和提示词类型init / sync返回对应提示词字符串init 类型扫描目录结构 + 读取 README/package.json/Cargo.toml/ARCHITECTURE.md/CLAUDE.md/AGENTS.md/DEVELOPMENT.md存在才读不存在跳过monorepo 自动检测pnpm-workspace.yaml/lerna.json/turbo.json检测到则额外读 apps/ 前4个子包 + packages/ 前2个子包的 package.json并在提示词中注入 monorepo 提示(模块数建议 10-25、以业务能力为单位划分sync 类型额外读取现有蓝图文件 + 近 20 条 git log
### 📋 项目列表蓝图状态徽章
- status: todo
### 项目列表蓝图状态徽章
- status: done
- complexity: M
- files: src/components/dashboard/ProjectCard.tsx
- depends: get_blueprint_status command
- acceptance: 项目卡片显示蓝图状态徽章(🔘无蓝图 / 🟡规则待更新 / 🟠内容待同步 / 🟢已同步);🟡状态点击直接执行同步(无需 Claude其余状态点击打开操作弹窗
### 📋 蓝图操作弹窗
- status: todo
### 蓝图操作弹窗
- status: done
- complexity: M
- files: src/components/dashboard/BlueprintPromptModal.tsx
- depends: generate_blueprint_prompt command
- acceptance: 弹窗展示生成的提示词monospace、可选中、一键复制按钮、说明文案"复制后粘贴给 Claude Opus 执行");🔘状态显示「初始化蓝图」提示词,🟠状态显示「同步描绘」提示词

View File

@ -1,8 +1,8 @@
---
id: blueprint-view
name: 蓝图可视化
status: done
progress: 100
status: in_progress
progress: 86
---
## 概述
@ -78,6 +78,30 @@ progress: 100
- files: src/components/dashboard/BlueprintModal.tsx
- acceptance: 根据模块相对位置自动选择最短路径 handle避免连线绕远
### ✅ 蓝图治理面板
- status: done
- complexity: M
- files: src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
- acceptance: 侧边新增「治理」面板,展示 CONVENTIONS.md 当前版本与 master 版本对比;提供「同步规则」按钮(替代设置页的同步入口);提供「初始化/同步蓝图」提示词入口(替代卡片徽章弹窗,统一收口)
### ✅ 顶部状态行扩展
- status: done
- complexity: S
- files: src/components/dashboard/BlueprintModal.tsx
- depends: blueprint-governance
- acceptance: 统计条新增「规则」状态指示(🟢已同步/🟡待更新点击跳转到治理面板「AI文档」指示器预留位置但灰显depends: blueprint-feedback 未完成时不可用,避免跳转到不存在的面板)
### ✅ 批量执行提示词入口
- status: done
- complexity: S
- files: src/components/dashboard/BlueprintModal.tsx
- depends: blueprint-view
- acceptance: 下一步汇总面板新增「复制批量执行提示词」按钮;点击后将当前项目的批量执行提示词复制到剪贴板;提示词包含项目名称,指示 Claude 自主遍历所有 📋 任务卡依次执行(标记 🔵 加锁 → 实现 → 标记 ✅ 解锁,失败标 🔴 blocked中途不等用户确认全部完成后输出执行报告提示词纯前端生成无需新增 Tauri command
### 💭 AI 文档健康面板
- status: concept
- notes: 侧边新增「AI文档」面板列出项目内所有 AI 相关文档CLAUDE.md/AGENTS.md/.cursorrules 等)及其使用频率评分;标出休眠文档;提供「归档」操作。依赖 ai-doc-analytics 模块的埋点数据。
## 决策记录
- 选择 React Flow (@xyflow/react) 而非 D3 或自绘 canvas因为 React Flow 提供开箱即用的节点拖拽、缩放、连线路由
@ -85,3 +109,4 @@ progress: 100
- 区域分组用 React Flow 父子节点实现,模块作为子节点自动跟随分组
- hover 只做边高亮,点击(pin)才做节点淡化,避免 React Flow 节点重建导致的闪烁
- 飞轮模型分配Opus 做架构需求和蓝图维护Sonnet 执行任务卡,.blueprint/ 文件作为模型间共享状态
- 蓝图弹窗定位为项目蓝图驾驶舱所有蓝图相关操作治理、AI文档健康、提示词生成统一收口到此弹窗不再分散到设置页和卡片徽章

View File

@ -0,0 +1,40 @@
# MCP 配置注入
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
## 决策记录
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key与用户自有配置严格隔离
- 炼境只操作自己命名空间下的 key其余配置永远不动
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
## 任务卡
### ✅ 加入产品组时注入配置
- status: done
- complexity: M
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/groups.rs
- depends: mcp-server
- acceptance:
- 项目加入产品组后,其 win_path 或 wsl_path 对应的 `.claude/settings.json` 被写入以下条目
- key 格式:`lian-jing:group-{group_id}`
- value`{ "type": "http", "url": "http://localhost:{port}/mcp/group/{group_id}" }`
- 文件不存在时自动创建;文件已存在时合并写入,不影响其他 key
- 文件 JSON 损坏时 eprintln 记录,不影响成员关系 DB 写入
### ✅ 退出产品组时清除配置
- status: done
- complexity: S
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/groups.rs
- depends: mcp-config-inject注入
- acceptance:
- 项目退出产品组后,`lian-jing:group-{group_id}` 这一 key 被精确删除
- 其他 key包括其他产品组的 key、用户自有配置完全不受影响
- 在 DB 删除前先执行清除remove_project_mcp 需要 project_workspaces 查路径)
### ✅ 端口变更时重新注入所有配置
- status: done
- complexity: S
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/settings.rs
- acceptance: update_settings 检测到 mcp_port key 变更后自动调用 reinject_all_mcp遍历所有 project_group_map 记录重新写入 settings.json

View File

@ -0,0 +1,41 @@
# 内嵌 MCP Server
炼境进程内置一个 axum HTTP 服务器,实现 MCP 协议,按产品组聚合上下文数据,供组内所有项目的 Claude 实例查询。
## 决策记录
- 内嵌在 Tauri 进程而非独立进程,因为炼境托盘常驻时天然保证服务存活,无需额外进程管理
- 使用 HTTP/SSE 传输而非 stdio因为 HTTP 可被 Windows 和 WSL 两侧同时访问WSL localhost 穿透)
- 默认端口 27190冷门端口冲突概率极低可在设置页修改
- MCP 工具按 group_id 路由,不同产品组的数据完全隔离
## 任务卡
### ✅ 启动时绑定 HTTP 端口
- status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs, src-tauri/src/lib.rs, src-tauri/Cargo.toml
- depends: tray-service
- acceptance: 炼境启动后 `http://localhost:27190/health` 返回 200进程退出后端口释放
### ✅ 实现 MCP JSON-RPC 基础协议
- status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs
- acceptance: 支持 `initialize`、`tools/list`、`tools/call` 三个 JSON-RPC 方法,通过 MCP Inspector 验证
### ✅ 实现产品组上下文工具
- status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs
- depends: mcp-server基础协议
- acceptance: 以下三个工具可用且返回正确数据
- `list_group_projects(group_id)` → 组内项目列表名称、路径、平台、tech_stack
- `get_project_blueprint(group_id, project_id)` → 对应项目的 manifest.yaml 内容
- `get_project_file(group_id, project_id, relative_path)` → 对应项目的任意文件内容
### ✅ MCP 端口可配置Rust 侧)
- status: done
- complexity: S
- files: src-tauri/src/db.rs, src-tauri/src/mcp_server.rs
- acceptance: settings 表默认写入 mcp_port=27190启动时读取该值绑定端口SettingsPage UI 见 product-group-mcp 模块)

View File

@ -0,0 +1,25 @@
# 产品组 MCP 设置
产品组 UI 中展示 MCP 激活状态,让用户可感知哪些项目已接入跨项目上下文能力。
## 任务卡
### ✅ 产品组详情显示 MCP 注入状态
- status: done
- complexity: S
- files: src/components/manage/ManagePage.tsx
- depends: mcp-config-inject
- acceptance:
- 产品组成员卡片显示平台标识Win / WSL
- 已配置路径的项目显示绿点 "MCP" 徽标hover 显示路径
- 未配置路径的项目显示「无路径」灰色徽标,说明注入已跳过
### ✅ 设置页添加 MCP 端口配置
- status: done
- complexity: S
- files: src/components/settings/SettingsPage.tsx, src-tauri/src/commands/settings.rs
- depends: mcp-server端口可配置
- acceptance:
- 设置页新增「MCP 服务」分区,含端口输入框(默认 27190和绿色「运行中」状态
- 保存时校验端口范围1024~65535调用 updateSettings({mcp_port})
- Rust 侧自动触发 reinject_all_mcp所有成员配置即时更新

View File

@ -0,0 +1,28 @@
# 系统托盘常驻
炼境作为后台服务常驻,关闭窗口后进程继续运行,保持 MCP Server 持续提供服务。
## 决策记录
- 选择托盘常驻而非动态端口,因为 MCP 配置注入写静态 URL进程不常驻则 Claude 连不上
- 使用 tauri-plugin-single-instance 保证进程唯一,从根本上消除端口冲突风险
## 任务卡
### ✅ 集成 tauri-plugin-single-instance
- status: done
- complexity: S
- files: src-tauri/Cargo.toml, src-tauri/src/lib.rs, src-tauri/tauri.conf.json
- acceptance: 双击启动炼境时不新建进程,自动激活已有窗口并置顶
### ✅ 系统托盘图标与菜单
- status: done
- complexity: S
- files: src-tauri/src/lib.rs, src-tauri/tauri.conf.json
- acceptance: 任务栏托盘显示炼境图标,右键菜单有「显示窗口」「退出炼境」两项
### ✅ 关闭窗口最小化到托盘
- status: done
- complexity: S
- files: src-tauri/src/lib.rs
- acceptance: 点击窗口关闭按钮时,窗口隐藏但进程不退出;点「退出炼境」才真正结束进程

26
.blueprint/usage.json Normal file
View File

@ -0,0 +1,26 @@
{
"project_name": "dev-manager-tauri",
"snapshots": [
{
"at": "2026-04-03T04:52:50Z",
"blocked_reasons": [],
"conventions_version": "1.1.0",
"modules": {
"blocked": 0,
"concept": 0,
"done": 22,
"in_progress": 1,
"planned": 2,
"total": 25
},
"tasks": {
"blocked": 0,
"dispatched": 4,
"done": 84,
"in_progress": 0,
"todo": 7,
"total": 95
}
}
]
}

View File

@ -53,7 +53,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Dev Manager ${{ github.ref_name }}
name: 炼境 ${{ github.ref_name }}
body: |
## 更新内容
请查看提交记录了解详情。
@ -69,7 +69,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: Dev Manager ${{ github.ref_name }}
releaseName: 炼境 ${{ github.ref_name }}
releaseBody: |
## 更新内容
请查看提交记录了解详情。

View File

@ -1,4 +1,4 @@
# Dev Manager Tauri
# 炼境 (Dev Manager Tauri)
Git 多空间开发管理器Tauri v2 + React + TypeScript + Rust。
@ -42,3 +42,28 @@ src-tauri/src/commands/ Tauri command 实现
.blueprint/ 项目蓝图manifest + 模块详情 + 任务卡)
docs/ 部署和更新指南
```
<!-- BLUEPRINT_MANAGED_START version="1.3.0" -->
## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
**每次对话都应遵循以下流程:**
### 1. 了解当前状态
对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。
### 2. 讨论需求时更新蓝图
- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/<id>.md`
- 拆分了大需求 → 在模块文件中添加任务卡
- 调整了方向 → 更新模块关系edges
### 3. 完成实现后标记蓝图
- 完成了功能 → 更新对应任务卡状态为 `done`(前缀改为 ✅)
- 更新 manifest.yaml 顶部的 `updated` 日期
### 4. 任务卡规范
详见 `.blueprint/CONVENTIONS.md`,核心规则:
- 任务卡同时具备 `files` + `acceptance` + complexity `S/M` → 标记为 📋 可派发
- 可派发的任务卡可以直接交给 Claude Agent 独立完成
<!-- BLUEPRINT_MANAGED_END -->

99
src-tauri/Cargo.lock generated
View File

@ -234,6 +234,61 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -827,6 +882,7 @@ dependencies = [
name = "dev-manager-tauri"
version = "0.1.11"
dependencies = [
"axum",
"base64 0.22.1",
"chrono",
"git2",
@ -845,7 +901,9 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"ureq",
"urlencoding",
"uuid",
@ -1780,6 +1838,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@ -1794,6 +1858,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@ -2406,6 +2471,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
@ -4050,6 +4121,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@ -4792,6 +4874,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
@ -5222,6 +5319,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5260,6 +5358,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",

View File

@ -34,6 +34,9 @@ git2 = { version = "0.19", features = ["vendored-openssl"] }
tauri-plugin-http = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-single-instance = "2"
axum = "0.7"
tokio = { version = "1", features = ["net"] }
ureq = { version = "2", features = ["json"] }
open = "5"
base64 = "0.22"

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
use crate::db::pool;
use crate::mcp_inject;
use crate::models::{ProductGroup, ProjectGroupMap};
use rusqlite::params;
use serde::Deserialize;
@ -64,6 +65,19 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
#[tauri::command]
pub fn delete_group(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除所有成员的 MCP 配置DB cascade 删除之前)
let member_ids: Vec<String> = conn
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
.map_err(|e| e.to_string())?
.query_map(params![id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for project_id in &member_ids {
if let Err(e) = mcp_inject::remove_project_mcp(project_id, &id) {
eprintln!("[MCP remove] 删除组时清除失败 project={} group={}: {}", project_id, id, e);
}
}
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
@ -102,12 +116,20 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
params![project_id, group_id, role],
)
.map_err(|e| e.to_string())?;
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
if let Err(e) = mcp_inject::inject_project_mcp(&project_id, &group_id) {
eprintln!("[MCP inject] 注入失败 project={} group={}: {}", project_id, group_id, e);
}
Ok(())
}
#[tauri::command]
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
eprintln!("[MCP remove] 清除失败 project={} group={}: {}", project_id, group_id, e);
}
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
params![project_id, group_id],

View File

@ -1,4 +1,5 @@
use crate::db::pool;
use crate::mcp_inject;
use crate::models::{ActivityLog, Project};
use rusqlite::params;
use serde::{Deserialize, Serialize};
@ -276,6 +277,20 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
)
.unwrap_or((None, None, None));
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
let group_ids: Vec<String> = conn
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
.map_err(|e| e.to_string())?
.query_map(params![id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for group_id in &group_ids {
if let Err(e) = mcp_inject::remove_project_mcp(&id, group_id) {
eprintln!("[MCP remove] 删除项目时清除失败 project={} group={}: {}", id, group_id, e);
}
}
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
for table in &["activity_log", "project_tools", "project_group_map",
"project_env_tools", "deploy_commands"] {

View File

@ -12,6 +12,7 @@ const GLOBAL_KEYS: &[&str] = &[
"v2rayn_db_path", // v2rayN 数据库路径
"v2raya_username", // v2rayA 登录用户名
"v2raya_password", // v2rayA 登录密码
"mcp_port", // MCP Server 监听端口(全局,与空间无关)
];
/// 获取 settings
@ -60,7 +61,11 @@ pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>,
#[tauri::command]
pub fn update_settings(settings: HashMap<String, String>, space_id: Option<String>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
for (k, v) in settings {
let mut mcp_port_changed = false;
for (k, v) in &settings {
if k == "mcp_port" {
mcp_port_changed = true;
}
if GLOBAL_KEYS.contains(&k.as_str()) || space_id.is_none() {
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
@ -75,6 +80,13 @@ pub fn update_settings(settings: HashMap<String, String>, space_id: Option<Strin
.map_err(|e| e.to_string())?;
}
}
// 端口变更后重新注入所有产品组成员的 MCP 配置
if mcp_port_changed {
let errors = crate::mcp_inject::reinject_all_mcp();
if !errors.is_empty() {
eprintln!("[MCP reinject] 部分项目重注入失败: {:?}", errors);
}
}
Ok(())
}

View File

@ -128,6 +128,7 @@ fn migrate(conn: &rusqlite::Connection) {
INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
",
)
.expect("migrate");

View File

@ -1,5 +1,7 @@
mod commands;
mod db;
mod mcp_inject;
mod mcp_server;
mod models;
use git2;
@ -8,6 +10,15 @@ use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_o
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
// 第二个实例启动时,激活已有窗口
use tauri::Manager;
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}))
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
@ -15,7 +26,12 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
use tauri::Manager;
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
let data_dir = app.path().app_data_dir().expect("no app data dir");
db::init(data_dir);
// 禁用 libgit2 目录所有者验证,允许访问 WSL UNC 路径(\\wsl.localhost\...
@ -24,6 +40,59 @@ pub fn run() {
}
// 种植内置模板(首次启动时执行)
commands::templates::seed_builtin_templates();
// ── 启动 MCP Server ───────────────────────────────────────
let mcp_port = mcp_server::read_port();
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
// ── 系统托盘 ──────────────────────────────────────────────
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("炼境")
.on_menu_event(|app, event| match event.id.as_ref() {
"quit" => app.exit(0),
"show" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
// 左键单击托盘图标 → 显示/聚焦窗口
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
})
.build(app)?;
// ── 关闭窗口 → 最小化到托盘 ──────────────────────────────
if let Some(window) = app.get_webview_window("main") {
let win = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = win.hide();
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -150,6 +219,12 @@ pub fn run() {
generate_workflow,
// blueprint
get_blueprint,
generate_blueprint_prompt,
get_blueprint_status,
sync_blueprint_rules,
get_flywheel_stats,
generate_muhe_prompt,
archive_project_docs,
// canvas state
get_canvas_state,
save_canvas_state,

142
src-tauri/src/mcp_inject.rs Normal file
View File

@ -0,0 +1,142 @@
use crate::db;
use serde_json::{json, Value};
use std::path::PathBuf;
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 解析项目根目录路径WSL 项目自动转为 Windows UNC 路径)
fn project_root(project_id: &str) -> Result<PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) =
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
let distro: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(PathBuf::from(win))
}
}
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".claude").join("settings.json"))
}
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
fn read_json(path: &PathBuf) -> Result<Value, String> {
if !path.exists() {
return Ok(json!({}));
}
let content = std::fs::read_to_string(path)
.map_err(|e| format!("读取 settings.json 失败: {}", e))?;
if content.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&content).map_err(|e| {
format!(
"settings.json 格式错误,请手动修复后重试({}: {}",
path.display(),
e
)
})
}
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
}
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
}
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
let port = crate::mcp_server::read_port();
let path = settings_path(project_id)?;
let mut settings = read_json(&path)?;
let servers = settings
.as_object_mut()
.ok_or("settings.json 顶层必须是 JSON 对象")?
.entry("mcpServers")
.or_insert(json!({}));
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
servers[&key] = json!({
"type": "http",
"url": format!("http://localhost:{}/mcp/group/{}", port, group_id)
});
write_json(&path, &settings)
}
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
let path = settings_path(project_id)?;
if !path.exists() {
return Ok(());
}
let mut settings = read_json(&path)?;
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(&key);
}
write_json(&path, &settings)
}
/// 端口变更后重新注入所有产品组成员的 MCP 配置
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => return vec![e.to_string()],
};
let mappings: Vec<(String, String)> = {
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
Ok(s) => s,
Err(e) => return vec![e.to_string()],
};
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
let mut errors = Vec::new();
for (project_id, group_id) in mappings {
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
errors.push(format!("项目 {}: {}", project_id, e));
}
}
errors
}

289
src-tauri/src/mcp_server.rs Normal file
View File

@ -0,0 +1,289 @@
use axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::net::SocketAddr;
use crate::db;
/// 从 settings 表读取 MCP 端口,默认 27190
pub fn read_port() -> u16 {
db::pool()
.get()
.ok()
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'mcp_port'",
[],
|r| r.get::<_, String>(0),
)
.ok()
})
.and_then(|v| v.parse().ok())
.unwrap_or(27190)
}
/// 启动 MCP HTTP 服务Streamable HTTP transport
pub async fn start(port: u16) {
let app = Router::new()
.route("/health", get(health))
.route("/mcp/group/:group_id", post(handle_mcp));
let addr = SocketAddr::from(([127, 0, 0, 1], port));
match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => {
println!("[MCP] 炼境 MCP Server 已启动,端口 {port}");
if let Err(e) = axum::serve(listener, app).await {
eprintln!("[MCP] Server error: {e}");
}
}
Err(e) => eprintln!("[MCP] 绑定端口 {port} 失败: {e}"),
}
}
async fn health() -> &'static str {
"炼境 MCP Server running"
}
// ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct RpcRequest {
#[allow(dead_code)]
jsonrpc: String,
method: String,
id: Option<Value>,
#[serde(default)]
params: Option<Value>,
}
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
// MCP 通知(无 id→ 202 Accepted无需响应体
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
let result = match req.method.as_str() {
"initialize" => json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": {
"name": "炼境",
"version": env!("CARGO_PKG_VERSION")
}
}),
"tools/list" => tools_list(),
"tools/call" => tools_call(&group_id, req.params.as_ref()).await,
_ => {
return Json(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": "Method not found" }
}))
.into_response();
}
};
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response()
}
// ── 工具定义 ──────────────────────────────────────────────────────────────────
fn tools_list() -> Value {
json!({
"tools": [
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "get_project_blueprint",
"description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml了解该项目的模块与任务状态",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
},
{
"name": "get_project_file",
"description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID"
},
"relative_path": {
"type": "string",
"description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts"
}
},
"required": ["project_id", "relative_path"]
}
}
]
})
}
// ── 工具调用 ──────────────────────────────────────────────────────────────────
async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let tool_name = params
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let gid = group_id.to_string();
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"list_group_projects" => list_group_projects(&gid),
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_blueprint(&gid, pid)
}
"get_project_file" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let rel = args
.get("relative_path")
.and_then(|v| v.as_str())
.unwrap_or("");
get_project_file(&gid, pid, rel)
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
match outcome {
Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }),
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
}
}
// ── 工具实现(同步,由 spawn_blocking 包裹)──────────────────────────────────
fn list_group_projects(group_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path,
pp.tech_stack, pgm.role
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pgm.group_id = ?1",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map([group_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let platform: Option<String> = row.get(2)?;
let win_path: Option<String> = row.get(3)?;
let wsl_path: Option<String> = row.get(4)?;
let tech_stack: Option<String> = row.get(5)?;
let role: Option<String> = row.get(6)?;
Ok(format!(
"- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}",
name,
id,
platform.as_deref().unwrap_or("unknown"),
tech_stack.as_deref().unwrap_or("未配置"),
role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(),
win_path.as_deref().unwrap_or("未配置"),
wsl_path.as_deref().unwrap_or("未配置"),
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("该产品组暂无成员项目。".to_string());
}
Ok(format!(
"产品组成员(共 {} 个项目):\n\n{}",
rows.len(),
rows.join("\n\n")
))
}
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
let root = resolve_project_root(group_id, project_id)?;
let path = root.join(".blueprint").join("manifest.yaml");
std::fs::read_to_string(&path)
.map_err(|e| format!("读取蓝图失败({}: {}", path.display(), e))
}
fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result<String, String> {
if relative_path.contains("..") {
return Err("路径不允许包含 \"..\"".to_string());
}
let root = resolve_project_root(group_id, project_id)?;
let path = root.join(relative_path);
std::fs::read_to_string(&path)
.map_err(|e| format!("读取文件失败({}: {}", path.display(), e))
}
/// 根据 group_id + project_id 解析项目根目录路径
/// WSL 项目自动转换为 Windows UNC 路径(\\wsl.localhost\<distro>\...
fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT pw.win_path, pw.wsl_path, pw.platform
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
WHERE pgm.group_id = ?1 AND pgm.project_id = ?2",
[group_id, project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row.map_err(|_| {
format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)
})?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
let distro: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
// /home/user/project → \\wsl.localhost\Ubuntu\home\user\project
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(std::path::PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(std::path::PathBuf::from(win))
}
}

View File

@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Dev Manager",
"title": "炼境",
"width": 1280,
"height": 800,
"minWidth": 900,

View File

@ -11,16 +11,30 @@ import { Toast } from "./components/ui/Toast";
import LoginPage from "./components/auth/LoginPage";
import { githubGetAccount, githubLogout, type GitAccount } from "./lib/commands";
import { useSpacesSync } from "./hooks/useSpacesSync";
import { useUIStore } from "./store/ui";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
export default function App() {
const [page, setPage] = useState<Page>("overview");
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
useEffect(() => {
githubGetAccount().then(setAccount).catch(() => setAccount(null));
}, []);
// 启动时静默检查更新
useEffect(() => {
checkUpdate()
.then((update) => {
if (update?.available) {
showToast(`🆕 发现新版本 v${update.version},前往「设置」页更新`);
}
})
.catch(() => {}); // 静默失败,不打扰用户
}, []);
const handleLogout = async () => {
await githubLogout();
qc.clear();

View File

@ -14,10 +14,22 @@ import {
import "@xyflow/react/dist/style.css";
import {
getBlueprint,
getBlueprintStatus,
syncBlueprintRules,
generateBlueprintPrompt,
getProjects,
getFlywheelStats,
generateMuhePrompt,
archiveProjectDocs,
type BlueprintData,
type BlueprintModule,
type BlueprintTask,
type BlueprintArea,
type BlueprintStatus,
type SyncResult,
type Project,
type FlywheelStats,
type StalledProject,
} from "../../lib/commands";
interface Props {
@ -222,7 +234,7 @@ function pickHandles(
// ── 主组件 ───────────────────────────────────────────────────────────────────
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | null;
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | { type: "governance" } | { type: "flywheel" } | null;
export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
const [sidePanel, setSidePanel] = useState<SidePanel>(null);
@ -237,6 +249,12 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
queryFn: () => getBlueprint(projectPath),
});
const { data: bpStatus } = useQuery<BlueprintStatus>({
queryKey: ["bp-status", projectPath],
queryFn: () => getBlueprintStatus(projectPath),
enabled: !!projectPath,
});
const handleNodeClick = useCallback((mod: BlueprintModule) => {
setSidePanel({ type: "module", module: mod });
setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id));
@ -363,14 +381,44 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
)}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
×
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
sidePanel?.type === "flywheel"
? "text-white bg-emerald-600"
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
}`}
>
</button>
<button
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
sidePanel?.type === "governance"
? "text-white bg-violet-600"
: "text-violet-600 bg-violet-50 hover:bg-violet-100"
}`}
>
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
×
</button>
</div>
</div>
{/* 统计条 */}
{stats && (
<div className="px-5 py-2.5 border-b border-gray-100 flex items-center gap-4 shrink-0 flex-wrap">
{blueprint?.manifest.iteration != null && blueprint.manifest.iteration > 1 && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600 shrink-0 font-medium"
title="当前迭代号(模块重构时递增)"
>
{blueprint.manifest.iteration}
</span>
)}
<StatBadge color="#22C55E" label="已完成" count={stats.done} />
<StatBadge color="#3B82F6" label="进行中" count={stats.in_progress} />
<StatBadge color="#EAB308" label="已规划" count={stats.planned} />
@ -406,6 +454,30 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
📋 {stats.dispatchable}
</button>
)}
{/* 规则 & AI文档 状态指示器 */}
{bpStatus && (
<button
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
bpStatus.status === "synced"
? "text-green-600 bg-green-50 hover:bg-green-100"
: "text-yellow-600 bg-yellow-50 hover:bg-yellow-100"
}`}
>
{bpStatus.status === "synced" ? "🟢 规则" : "🟡 规则"}
</button>
)}
<button
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
sidePanel?.type === "flywheel"
? "text-white bg-emerald-600"
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
}`}
title="梦核·记忆巩固 / AI文档归档"
>
AI文档
</button>
{/* 总进度条(固定在右侧) */}
<div className="flex-1" />
{pinnedModuleId && (
@ -514,9 +586,19 @@ export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
) : sidePanel.type === "governance" ? (
<GovernancePanel
projectPath={projectPath}
projectName={projectName}
onClose={() => setSidePanel(null)}
onOpenFlywheel={() => setSidePanel({ type: "flywheel" })}
/>
) : sidePanel.type === "flywheel" ? (
<FlywheelPanel onClose={() => setSidePanel(null)} />
) : (
<NextActionsPanel
modules={blueprint?.manifest.modules ?? []}
projectName={projectName}
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
@ -747,13 +829,40 @@ function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: stri
function NextActionsPanel({
modules,
projectName,
onSelectModule,
onClose,
}: {
modules: BlueprintModule[];
projectName: string;
onSelectModule: (mod: BlueprintModule) => void;
onClose: () => void;
}) {
const [batchCopied, setBatchCopied] = useState(false);
const handleCopyBatchPrompt = () => {
const prompt = [
`当前项目:${projectName}`,
``,
`读取 .blueprint/manifest.yaml 和所有 .blueprint/modules/*.md`,
`以及 .blueprint/CONVENTIONS.md 了解执行规则。`,
``,
`【处理断点】先检查是否有 🔵 in_progress 任务卡(上次中断的),`,
`若有,读取其 files 字段判断已完成什么、还缺什么,从断点继续。`,
``,
`【批量执行】找出所有 📋 todo 任务卡,按任务卡 depends 字段及 manifest.yaml edges 综合拓扑排序后依次执行:`,
`- 执行前:前缀改为 🔵信息1·加锁`,
`- 执行依据:任务卡的 files 字段(涉及哪些文件)+ acceptance 字段(验收标准)`,
`- 完成后:前缀改为 ✅信息2·解锁若模块所有任务卡均完成模块 status 改为 done`,
`- 更新 manifest.yaml 的 updated 日期`,
`- 无法独立解决:标记 🔴 blocked + blocked_reason跳过继续下一张`,
``,
`中途不需要等我确认,直接执行到底,全部完成后输出执行报告。`,
].join("\n");
navigator.clipboard.writeText(prompt);
setBatchCopied(true);
setTimeout(() => setBatchCopied(false), 2000);
};
// 收集所有可派发和进行中的任务
const actionItems = useMemo(() => {
const items: { module: BlueprintModule; task: BlueprintTask; dispatchable: boolean }[] = [];
@ -784,7 +893,16 @@ function NextActionsPanel({
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"></h3>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
<div className="flex items-center gap-2">
<button
onClick={handleCopyBatchPrompt}
className="text-[11px] px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
title="复制批量执行提示词,粘贴给 Claude 自动完成所有 📋 任务卡"
>
{batchCopied ? "已复制 ✓" : "🚀 批量执行"}
</button>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
</div>
{actionItems.length === 0 ? (
@ -877,6 +995,225 @@ function NextActionsPanel({
);
}
// ── 蓝图治理面板 ────────────────────────────────────────────────────────────
function GovernancePanel({
projectPath,
projectName,
onClose,
onOpenFlywheel,
}: {
projectPath: string;
projectName: string;
onClose: () => void;
onOpenFlywheel?: () => void;
}) {
const { data: bpStatus, isPending: statusLoading, refetch: refetchStatus } = useQuery<BlueprintStatus>({
queryKey: ["bp-status-governance", projectPath],
queryFn: () => getBlueprintStatus(projectPath),
});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
const [prompt, setPrompt] = useState("");
const [promptLoading, setPromptLoading] = useState(false);
const [promptError, setPromptError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleSyncRules = async () => {
setSyncing(true);
setSyncError(null);
setSyncResult(null);
try {
const res = await syncBlueprintRules(projectPath);
setSyncResult(res);
refetchStatus();
} catch (e) {
setSyncError(String(e));
} finally {
setSyncing(false);
}
};
const handleOpenPrompt = async (mode: "init" | "sync") => {
if (promptMode === mode) {
setPromptMode(null);
return;
}
setPromptMode(mode);
setPrompt("");
setPromptError(null);
setPromptLoading(true);
try {
const result = await generateBlueprintPrompt(projectPath, mode);
setPrompt(result);
} catch (e) {
setPromptError(String(e));
} finally {
setPromptLoading(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const rulesOutdated = bpStatus?.status === "rules_outdated";
const currentVersion = bpStatus?.conventions_version ?? "—";
const masterVersion = bpStatus?.master_version ?? "—";
// 用后端完整状态判断rules_outdated 才是真正未同步content_stale/synced 都意味着规则已同步)
const versionSynced = bpStatus?.status === "synced" || bpStatus?.status === "content_stale";
return (
<div className="p-4 space-y-4">
{/* 标题 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"></h3>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{/* 规则版本状态 */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">CONVENTIONS </p>
{statusLoading ? (
<p className="text-xs text-gray-400"></p>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-mono text-gray-700">{currentVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-mono text-gray-700">{masterVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
{versionSynced ? (
<span className="text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded">🟢 </span>
) : (
<span className="text-xs text-yellow-600 bg-yellow-50 px-1.5 py-0.5 rounded">🟡 </span>
)}
</div>
</div>
)}
</div>
{/* 同步规则区域 */}
{rulesOutdated && !syncResult && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-800 font-semibold"></p>
<p className="text-[11px] text-yellow-700 leading-relaxed">
<code className="font-mono bg-yellow-100 px-1 rounded">.blueprint/CONVENTIONS.md</code> CLAUDE.md
</p>
{syncError && (
<p className="text-[11px] text-red-600 font-mono break-all">{syncError}</p>
)}
<button
onClick={handleSyncRules}
disabled={syncing}
className="w-full py-1.5 rounded-lg bg-yellow-500 text-white text-xs hover:bg-yellow-600 disabled:opacity-50 transition-colors"
>
{syncing ? "同步中…" : "同步规则"}
</button>
</div>
)}
{syncResult && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-xs text-green-700 font-semibold mb-1"> </p>
<p className="text-[11px] text-green-600 font-mono">{syncResult.message}</p>
</div>
)}
{/* 蓝图内容提示词 */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"></p>
<p className="text-[11px] text-gray-500 leading-relaxed">
Claude Opus
</p>
<div className="flex gap-2">
<button
onClick={() => handleOpenPrompt("init")}
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
promptMode === "init"
? "bg-blue-600 text-white"
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => handleOpenPrompt("sync")}
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
promptMode === "sync"
? "bg-blue-600 text-white"
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
</button>
</div>
</div>
{/* 提示词内容区 */}
{promptMode && (
<div className="space-y-2">
{promptLoading ? (
<p className="text-xs text-gray-400 text-center py-4"></p>
) : promptError ? (
<p className="text-xs text-red-500 font-mono break-all">{promptError}</p>
) : (
<>
<textarea
readOnly
value={prompt}
rows={12}
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2.5 resize-none outline-none focus:ring-2 focus:ring-blue-200"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopy}
className="w-full py-1.5 rounded-lg bg-blue-600 text-white text-xs hover:bg-blue-700 transition-colors"
>
{copied ? "已复制 ✓" : "复制提示词"}
</button>
<p className="text-[10px] text-gray-400 text-center"></p>
</>
)}
</div>
)}
{/* 梦核分析入口 */}
{onOpenFlywheel && (
<div className="bg-emerald-50 rounded-lg border border-emerald-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-emerald-600 uppercase tracking-wider"> · </p>
<p className="text-[11px] text-emerald-700 leading-relaxed">
Claude Opus /
</p>
<button
onClick={onOpenFlywheel}
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
>
</button>
</div>
)}
{/* 项目信息 */}
<div className="pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-300 truncate">{projectName}</p>
</div>
</div>
);
}
// ── 未完成任务面板 ──────────────────────────────────────────────────────────
const REMAINING_ORDER: Record<string, number> = { blocked: 0, in_progress: 1, todo: 2, concept: 3 };
@ -947,3 +1284,334 @@ function RemainingTasksPanel({
</div>
);
}
// ── 飞轮健康面板 ────────────────────────────────────────────────────────────
function StalledProjectRow({ p, projectsMap, onArchive }: {
p: StalledProject;
projectsMap: Record<string, string>; // name → path
onArchive?: (path: string, name: string) => void;
}) {
const path = projectsMap[p.name];
return (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-amber-700 truncate">· {p.name}</p>
<p className="text-[10px] text-amber-400">: {p.last_active}</p>
</div>
{onArchive && path && (
<button
onClick={() => onArchive(path, p.name)}
className="text-[10px] text-amber-600 bg-amber-100 hover:bg-amber-200 px-1.5 py-0.5 rounded shrink-0 transition-colors"
>
</button>
)}
</div>
);
}
function FlywheelPanel({ onClose }: { onClose: () => void }) {
const { data: projects } = useQuery<Project[]>({
queryKey: ["projects-for-flywheel"],
queryFn: () => getProjects(),
});
const { projectPaths, projectsMap } = useMemo(() => {
const paths = (projects ?? [])
.map((p) => p.win_path || p.wsl_path || "")
.filter(Boolean);
const map: Record<string, string> = {};
for (const p of (projects ?? [])) {
const path = p.win_path || p.wsl_path || "";
if (!path) continue;
// 用炼境项目名做 key
map[p.name] = path;
// 同时用路径末尾目录名做备用 keyusage.json 里存的是目录名)
const dirName = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
if (dirName && !map[dirName]) map[dirName] = path;
}
return { projectPaths: paths, projectsMap: map };
}, [projects]);
const { data: stats, isLoading } = useQuery<FlywheelStats>({
queryKey: ["flywheel-stats", projectPaths],
queryFn: () => getFlywheelStats(projectPaths),
enabled: projectPaths.length > 0,
});
const [muhePrompt, setMuhePrompt] = useState("");
const [generating, setGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [archiveMsg, setArchiveMsg] = useState<string | null>(null);
const [archiving, setArchiving] = useState<string | null>(null);
const [pendingArchive, setPendingArchive] = useState<{ path: string; name: string } | null>(null);
const handleGenerateMuhe = async () => {
if (!stats) return;
setGenerating(true);
try {
const result = await generateMuhePrompt(stats);
setMuhePrompt(result);
} finally {
setGenerating(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(muhePrompt).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleArchiveConfirm = async () => {
if (!pendingArchive) return;
const { path, name } = pendingArchive;
setPendingArchive(null);
setArchiving(name);
setArchiveMsg(null);
try {
const result = await archiveProjectDocs(path);
setArchiveMsg(`✓ 已归档 ${result.archived_files.length} 个文件 → ${result.archive_dir}`);
} catch (e) {
setArchiveMsg(`归档失败: ${String(e)}`);
} finally {
setArchiving(null);
}
};
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`;
// Fix 2去重——90天休眠的项目不在60天停转里重复展示
const stalledOnlyProjects = (stats?.stalled_projects ?? []).filter(
(p) => !(stats?.ai_doc_stale_projects ?? []).some((q) => q.name === p.name)
);
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800"></h3>
{stats && (
<p className="text-[10px] text-gray-400 mt-0.5">
{stats.projects_with_data}/{stats.total_projects_scanned}
</p>
)}
</div>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{isLoading ? (
<p className="text-xs text-gray-400 text-center py-6"></p>
) : !stats || stats.projects_with_data === 0 ? (
<div className="text-center py-6 space-y-2">
<p className="text-xs text-gray-400"></p>
<p className="text-[11px] text-gray-300"></p>
</div>
) : (
<>
{/* CONVENTIONS 版本队列对比 */}
{stats.cohorts.length > 0 && (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"></p>
<div className="space-y-3">
{stats.cohorts.map((c) => (
<div key={c.conventions_version} className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-mono text-gray-600">v{c.conventions_version}</span>
<span className="text-[10px] text-gray-400">{c.project_count} </span>
</div>
{/* Fix 4标签改为"任务完成率",字段改为 completion_rate */}
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
<MetricRow label="可派发率" value={fmtPct(c.dispatchable_rate)} good={c.dispatchable_rate > 0.3} />
<MetricRow label="任务完成率" value={fmtPct(c.completion_rate)} good={c.completion_rate > 0.5} />
<MetricRow label="飞轮持续率" value={fmtPct(c.continuity_rate)} good={c.continuity_rate > 0.6} />
<MetricRow label="阻塞密度" value={fmtPct(c.blocked_density)} good={c.blocked_density < 0.1} invert />
</div>
</div>
))}
</div>
</div>
)}
{/* TOP blocked reasons */}
{stats.top_blocked_reasons.length > 0 && (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">TOP </p>
<ul className="space-y-1">
{stats.top_blocked_reasons.map((r, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">#{i + 1}</span>
<span className="text-[11px] text-red-600 flex-1">{r.reason}</span>
<span className="text-[10px] text-gray-400 shrink-0">×{r.count}</span>
</li>
))}
</ul>
</div>
)}
{/* Fix 2仅展示"停转但未到90天"的项目 */}
{stalledOnlyProjects.length > 0 && (
<div className="bg-amber-50 rounded-lg border border-amber-100 p-3 space-y-1.5">
<p className="text-[10px] font-semibold text-amber-500 uppercase tracking-wider">
6090
</p>
{stalledOnlyProjects.map((p, i) => (
<StalledProjectRow key={i} p={p} projectsMap={projectsMap} />
))}
</div>
)}
{/* AI文档休眠项目≥90天 */}
{stats.ai_doc_stale_projects.length > 0 && (
<div className="bg-red-50 rounded-lg border border-red-100 p-3 space-y-1.5">
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider">
AI文档休眠90+
</p>
<p className="text-[10px] text-red-400 mb-1">
.blueprint/modules/ Claude
</p>
{/* Fix 1归档确认弹窗 */}
{pendingArchive ? (
<div className="bg-red-100 border border-red-200 rounded p-2.5 space-y-2">
<p className="text-[11px] text-red-700 font-semibold">
{pendingArchive.name}
</p>
<p className="text-[10px] text-red-500 leading-relaxed">
.blueprint/modules/*.md .blueprint/archive/
</p>
<div className="flex gap-2">
<button
onClick={handleArchiveConfirm}
disabled={!!archiving}
className="flex-1 py-1 rounded bg-red-600 text-white text-[11px] hover:bg-red-700 disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={() => setPendingArchive(null)}
className="flex-1 py-1 rounded border border-red-200 text-red-500 text-[11px] hover:bg-red-50 transition-colors"
>
</button>
</div>
</div>
) : (
stats.ai_doc_stale_projects.map((p, i) => (
<StalledProjectRow
key={i}
p={p}
projectsMap={projectsMap}
onArchive={archiving ? undefined : (path, name) => setPendingArchive({ path, name })}
/>
))
)}
{archiving && (
<p className="text-[10px] text-red-400"> {archiving} </p>
)}
{archiveMsg && (
<p className={`text-[10px] break-all ${archiveMsg.startsWith("✓") ? "text-green-600" : "text-red-600"}`}>
{archiveMsg}
</p>
)}
</div>
)}
{/* 迭代跳变项目 */}
{(stats.iteration_jumps ?? []).length > 0 && (
<div className="bg-purple-50 rounded-lg border border-purple-100 p-3 space-y-1.5">
<p className="text-[10px] font-semibold text-purple-500 uppercase tracking-wider">
</p>
{(stats.iteration_jumps ?? []).map((j, i) => (
<div key={i} className="flex items-center gap-2 text-[11px]">
<span className="text-gray-600 truncate flex-1">{j.project}</span>
<span className="text-purple-400 shrink-0"> {j.from_iteration}{j.to_iteration}</span>
<span className={`shrink-0 font-mono ${j.rate_after < j.rate_before - 0.3 ? "text-amber-500 font-semibold" : "text-gray-400"}`}>
{(j.rate_before * 100).toFixed(0)}%{(j.rate_after * 100).toFixed(0)}%
{j.rate_after < j.rate_before - 0.3 && " ⚠️"}
</span>
</div>
))}
</div>
)}
{/* 停滞模块 */}
{(stats.stalled_modules ?? []).length > 0 && (
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3 space-y-1.5">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
in_progress
</p>
<ul className="space-y-0.5">
{(stats.stalled_modules ?? []).map((m, i) => (
<li key={i} className="text-[11px] text-gray-500 font-mono">{m}</li>
))}
</ul>
</div>
)}
{/* 梦核分析 */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"></p>
<p className="text-[11px] text-gray-500 leading-relaxed">
Claude Opus /
</p>
{!muhePrompt ? (
<button
onClick={handleGenerateMuhe}
disabled={generating}
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{generating ? "生成中…" : "生成梦核提示词"}
</button>
) : (
<div className="space-y-2">
<textarea
readOnly
value={muhePrompt}
rows={10}
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2.5 resize-none outline-none"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopy}
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
>
{copied ? "已复制 ✓" : "复制提示词"}
</button>
<button
onClick={() => setMuhePrompt("")}
className="w-full py-1 rounded-lg text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
</button>
{/* Fix 3说清楚实际流程——梦核不自动写入需手动更新源码再发版 */}
<p className="text-[10px] text-gray-400 text-center leading-relaxed">
Claude Opus CONVENTIONS.md
</p>
</div>
)}
</div>
</>
)}
</div>
);
}
function MetricRow({ label, value, good, invert = false }: {
label: string;
value: string;
good: boolean;
invert?: boolean;
}) {
const isGood = invert ? !good : good;
return (
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-400">{label}</span>
<span className={`text-[10px] font-mono font-semibold ${isGood ? "text-green-600" : "text-yellow-600"}`}>
{value}
</span>
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useState, useEffect, useRef } from "react";
import { generateBlueprintPrompt, syncBlueprintRules, type BlueprintStatus, type SyncResult } from "../../lib/commands";
interface Props {
projectName: string;
projectPath: string;
bpStatus: BlueprintStatus["status"];
onClose: () => void;
onSynced?: () => void; // 同步完成后通知父组件刷新状态
}
// ── 规则同步确认模式rules_outdated────────────────────────────────────────
function SyncConfirmView({
projectName, projectPath, onClose, onSynced,
}: {
projectName: string;
projectPath: string;
onClose: () => void;
onSynced?: () => void;
}) {
const [syncing, setSyncing] = useState(false);
const [result, setResult] = useState<SyncResult | null>(null);
const [error, setError] = useState<string | null>(null);
const handleSync = async () => {
setSyncing(true);
setError(null);
try {
const res = await syncBlueprintRules(projectPath);
setResult(res);
onSynced?.();
} catch (e) {
setError(String(e));
} finally {
setSyncing(false);
}
};
return (
<>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div>
<h2 className="text-sm font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-400 mt-0.5">{projectName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{/* 说明 */}
<div className="px-5 py-4 flex flex-col gap-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-xs text-yellow-800 space-y-1.5">
<p className="font-semibold"></p>
<ul className="space-y-1 pl-3 list-disc">
<li> <code className="font-mono bg-yellow-100 px-1 rounded">.blueprint/CONVENTIONS.md</code></li>
<li> <code className="font-mono bg-yellow-100 px-1 rounded">CLAUDE.md</code> </li>
</ul>
<p className="text-yellow-600 pt-1"></p>
</div>
{result && (
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-xs text-green-800">
<p className="font-semibold mb-1"> </p>
<p className="font-mono text-green-700">{result.message}</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs text-red-700 font-mono break-all">
{error}
</div>
)}
</div>
{/* 底部操作 */}
<div className="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
{result ? "关闭" : "取消"}
</button>
{!result && (
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-1.5 rounded-lg bg-yellow-500 text-white text-sm hover:bg-yellow-600 disabled:opacity-50 transition-colors min-w-[80px]"
>
{syncing ? "同步中…" : "确认同步"}
</button>
)}
</div>
</>
);
}
// ── 提示词生成模式none / content_stale────────────────────────────────────
function PromptView({
projectName, projectPath, bpStatus, onClose,
}: {
projectName: string;
projectPath: string;
bpStatus: "none" | "content_stale";
onClose: () => void;
}) {
const [prompt, setPrompt] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptType = bpStatus === "none" ? "init" : "sync";
const title = bpStatus === "none" ? "初始化蓝图" : "同步蓝图描绘";
const hint = bpStatus === "none"
? "将以下提示词复制后粘贴给 Claude Opus让它为项目创建蓝图"
: "将以下提示词复制后粘贴给 Claude Opus让它更新项目蓝图";
useEffect(() => {
setLoading(true);
setError(null);
generateBlueprintPrompt(projectPath, promptType)
.then(setPrompt)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [projectPath, promptType]);
const handleCopy = () => {
navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div>
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
<p className="text-xs text-gray-400 mt-0.5">{projectName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{/* 说明 */}
<div className="px-5 py-3 bg-amber-50 border-b border-amber-100">
<p className="text-xs text-amber-700">
🤖 {hint} Claude
</p>
</div>
{/* 提示词区域 */}
<div className="flex-1 overflow-hidden flex flex-col px-5 py-4 gap-3">
{loading ? (
<div className="flex-1 flex items-center justify-center text-sm text-gray-400">
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-red-500 font-mono break-all">{error}</p>
</div>
) : (
<textarea
ref={textareaRef}
readOnly
value={prompt}
className="flex-1 font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-3 resize-none outline-none focus:ring-2 focus:ring-blue-200"
onClick={() => textareaRef.current?.select()}
/>
)}
</div>
{/* 底部操作 */}
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between">
<p className="text-xs text-gray-400"></p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleCopy}
disabled={loading || !!error}
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50 transition-colors min-w-[80px]"
>
{copied ? "已复制 ✓" : "复制提示词"}
</button>
</div>
</div>
</>
);
}
// ── 主组件 ────────────────────────────────────────────────────────────────────
export function BlueprintPromptModal({ projectName, projectPath, bpStatus, onClose, onSynced }: Props) {
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={handleBackdrop}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col h-[75vh]">
{bpStatus === "rules_outdated" ? (
<SyncConfirmView
projectName={projectName}
projectPath={projectPath}
onClose={onClose}
onSynced={onSynced}
/>
) : (
<PromptView
projectName={projectName}
projectPath={projectPath}
bpStatus={bpStatus as "none" | "content_stale"}
onClose={onClose}
/>
)}
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, type Project, type Space, type ActionsRun } from "../../lib/commands";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, type Project, type Space, type ActionsRun, type BlueprintStatus } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal";
@ -8,9 +8,48 @@ import { ProjectEnvModal } from "./ProjectEnvModal";
import { CicdModal } from "./CicdModal";
import { PublishModal } from "./PublishModal";
import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
// ── 蓝图状态徽章 ──────────────────────────────────────────────────────────────
const BP_BADGE: Record<BlueprintStatus["status"], { dot: string; label: string; title: string }> = {
none: { dot: "bg-gray-300", label: "无蓝图", title: "项目尚未接入蓝图,点击获取初始化提示词" },
rules_outdated: { dot: "bg-yellow-400", label: "规则待更新", title: "CONVENTIONS.md 版本落后,点击查看详情" },
content_stale: { dot: "bg-amber-400", label: "内容待同步", title: "蓝图内容可能落后于代码,点击获取同步提示词" },
synced: { dot: "bg-green-400", label: "已同步", title: "蓝图规则和内容均为最新" },
};
function BlueprintStatusBadge({
status, syncing, onOpenPrompt, onOpenViewer,
}: {
status: BlueprintStatus["status"];
syncing: boolean;
onOpenPrompt: () => void;
onOpenViewer: () => void;
}) {
const cfg = BP_BADGE[status];
const handleClick = () => {
if (status === "synced") { onOpenViewer(); return; }
onOpenPrompt(); // none / rules_outdated / content_stale
};
return (
<button
onClick={handleClick}
disabled={syncing}
title={cfg.title}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-gray-100 bg-gray-50 hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
<div className={`w-1.5 h-1.5 rounded-full ${cfg.dot} shrink-0`} />
<span className="text-[10px] text-gray-500">{syncing ? "同步中…" : cfg.label}</span>
</button>
);
}
interface Props {
project: Project;
role?: string;
@ -24,6 +63,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
const [showCicd, setShowCicd] = useState(false);
const [showPublish, setShowPublish] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false);
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
const [showBranchMenu, setShowBranchMenu] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [creatingBranch, setCreatingBranch] = useState(false);
@ -145,6 +185,18 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
retry: false,
});
// 蓝图路径:优先 win_pathWSL 项目仅当 wsl_path 为 UNC 格式时可从 Windows 侧访问
const blueprintPath = p.win_path
|| (p.wsl_path?.startsWith("\\\\wsl.") ? p.wsl_path : undefined);
const { data: bpStatus, refetch: refetchBpStatus, isPending: bpPending } = useQuery<BlueprintStatus>({
queryKey: ["blueprintStatus", p.id],
queryFn: () => getBlueprintStatus(blueprintPath!),
enabled: !!blueprintPath,
staleTime: 60000,
retry: false,
});
// PR 链接:从 repo_url 解析出 GitHub 路径
const prUrl = (() => {
if (!p.repo_url || !gitStat) return null;
@ -317,6 +369,15 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
</div>
))}
</div>
{/* 蓝图状态徽章:路径存在且查询已完成(成功或失败均显示) */}
{blueprintPath && !bpPending && (
<BlueprintStatusBadge
status={bpStatus?.status ?? "none"}
syncing={false}
onOpenPrompt={() => setShowBlueprintPrompt(true)}
onOpenViewer={() => setShowBlueprint(true)}
/>
)}
</div>
</div>
@ -696,11 +757,21 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
{showBlueprint && (p.win_path || p.wsl_path) && (
<BlueprintModal
projectName={p.name}
projectPath={p.win_path ?? p.wsl_path ?? ""}
projectPath={p.win_path || p.wsl_path || ""}
onClose={() => setShowBlueprint(false)}
/>
)}
{showBlueprintPrompt && blueprintPath && (
<BlueprintPromptModal
projectName={p.name}
projectPath={blueprintPath}
bpStatus={bpStatus?.status ?? "none"}
onClose={() => setShowBlueprintPrompt(false)}
onSynced={() => refetchBpStatus()}
/>
)}
{/* Expanded activity */}
{expanded && (
<div className="border-t border-gray-50 pt-3">

View File

@ -59,7 +59,7 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
{/* Logo */}
<div className="h-14 flex items-center px-4 border-b border-slate-700 shrink-0">
<span className="font-bold text-white text-sm tracking-tight">Dev Manager</span>
<span className="font-bold text-white text-sm tracking-tight"></span>
</div>
{/* 导航区(随当前空间) */}

View File

@ -295,9 +295,37 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
<div className="px-5 py-4 flex flex-wrap gap-2">
{members.map((m) => {
const p = projects.find((x) => x.id === m.project_id);
const hasPath = !!(p?.win_path || p?.wsl_path);
return p ? (
<div key={m.project_id} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-50 border border-gray-200 text-xs">
<span className="font-medium text-gray-700">{p.name}</span>
{/* 平台标识 */}
{p.platform && (
<span className={`px-1.5 py-0.5 rounded border text-xs ${
p.platform === "wsl"
? "bg-orange-50 text-orange-600 border-orange-200"
: "bg-blue-50 text-blue-600 border-blue-200"
}`}>
{p.platform === "wsl" ? "WSL" : "Win"}
</span>
)}
{/* MCP 注入状态 */}
{hasPath ? (
<span
className="flex items-center gap-1 px-1.5 py-0.5 rounded border bg-green-50 text-green-600 border-green-200"
title={`MCP 已注入:${p.win_path || p.wsl_path}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
MCP
</span>
) : (
<span
className="px-1.5 py-0.5 rounded border bg-gray-100 text-gray-400 border-gray-200"
title="项目未配置路径MCP 注入已跳过"
>
</span>
)}
{m.role && <span className="text-gray-400 border-l border-gray-200 pl-2">{m.role}</span>}
<button
onClick={async () => {

View File

@ -6,7 +6,8 @@ import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";
import {
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
type EditorCandidate,
getProjects, syncBlueprintRules,
type EditorCandidate, type SyncResult,
} from "../../lib/commands";
// GitHub OAuth 配置已移至侧边栏「应用设置」
import { useUIStore } from "../../store/ui";
@ -191,6 +192,7 @@ export function SettingsPage() {
const [detectingWsl, setDetectingWsl] = useState(false);
const [wslError, setWslError] = useState("");
const [formTarget, setFormTarget] = useState<EditorEntry | null | undefined>(undefined);
const [mcpPort, setMcpPort] = useState("27190");
useEffect(() => {
if (settings) {
@ -204,6 +206,7 @@ export function SettingsPage() {
}
setWslDistro(settings.wsl_distro ?? "Ubuntu");
setWslOpenMode(settings.wsl_open_mode ?? "wsl_internal");
setMcpPort(settings.mcp_port ?? "27190");
}
}, [settings]);
@ -252,6 +255,17 @@ export function SettingsPage() {
showToast("已保存 ✓");
};
const saveMcp = async () => {
const port = parseInt(mcpPort, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
showToast("❌ 端口范围1024 ~ 65535");
return;
}
await updateSettings({ mcp_port: mcpPort });
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
showToast("已保存,所有产品组成员配置已更新 ✓");
};
const detectWsl = async () => {
setDetectingWsl(true);
setWslError("");
@ -455,6 +469,47 @@ export function SettingsPage() {
</div>
</section>
{/* ── MCP 服务 ── */}
<section className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-700">MCP </h2>
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs bg-green-50 text-green-600 border border-green-200">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
</div>
<p className="text-xs text-gray-400">
MCP Server Claude
{" "}<code className="font-mono bg-gray-50 px-1 rounded">.claude/settings.json</code>{" "}
MCP
</p>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1"></label>
<div className="flex gap-2 items-center">
<input
type="number"
className="w-28 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400"
value={mcpPort}
onChange={(e) => setMcpPort(e.target.value)}
min={1024}
max={65535}
/>
<button
onClick={saveMcp}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
>
</button>
</div>
<p className="text-xs text-gray-400 mt-1.5">
MCP
<code className="font-mono bg-gray-50 px-1 rounded">
http://localhost:{mcpPort}/mcp/group/&lt;group_id&gt;
</code>
</p>
</div>
</section>
{/* 编辑器表单弹窗 */}
{formTarget !== undefined && (
<EditorFormModal
@ -464,11 +519,124 @@ export function SettingsPage() {
/>
)}
<BatchSyncSection />
<UpdateSection />
</div>
);
}
/* ── 蓝图规则批量同步区块 ────────────────────────────────────────────────────── */
interface SyncRow {
name: string;
path: string;
status: "pending" | "ok" | "error";
result?: SyncResult;
error?: string;
}
function BatchSyncSection() {
const [rows, setRows] = useState<SyncRow[]>([]);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const handleSync = async () => {
setRunning(true);
setDone(false);
let projects: Awaited<ReturnType<typeof getProjects>> = [];
try {
projects = await getProjects(undefined, undefined);
} catch {
setRunning(false);
return;
}
// 只处理有 win_path 的项目
const targets = projects
.filter((p) => p.win_path)
.map((p) => ({ name: p.name, path: p.win_path! }));
if (targets.length === 0) {
setRows([]);
setRunning(false);
setDone(true);
return;
}
const initial: SyncRow[] = targets.map((t) => ({ ...t, status: "pending" }));
setRows(initial);
const results = [...initial];
for (let i = 0; i < results.length; i++) {
try {
const result = await syncBlueprintRules(results[i].path);
results[i] = { ...results[i], status: "ok", result };
} catch (e) {
results[i] = { ...results[i], status: "error", error: String(e) };
}
setRows([...results]);
}
setRunning(false);
setDone(true);
};
return (
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-sm font-semibold text-gray-700"></h2>
<p className="text-xs text-gray-400 mt-0.5">
CONVENTIONS.md
</p>
</div>
<button
onClick={handleSync}
disabled={running}
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{running ? "同步中…" : "同步所有项目"}
</button>
</div>
{rows.length > 0 && (
<div className="space-y-1.5 max-h-60 overflow-y-auto">
{rows.map((row) => (
<div
key={row.path}
className={`flex items-start gap-2 px-3 py-2 rounded-lg text-xs ${
row.status === "ok"
? "bg-green-50 text-green-800"
: row.status === "error"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-400"
}`}
>
<span className="shrink-0 mt-0.5">
{row.status === "ok" ? "✓" : row.status === "error" ? "✗" : "…"}
</span>
<div className="min-w-0">
<span className="font-medium">{row.name}</span>
{row.status === "ok" && row.result && (
<span className="ml-2 opacity-70">{row.result.message}</span>
)}
{row.status === "error" && (
<span className="ml-2 font-mono break-all">{row.error}</span>
)}
</div>
</div>
))}
</div>
)}
{done && rows.length === 0 && (
<p className="text-xs text-gray-400"> win_path </p>
)}
</section>
);
}
/* ── 检查更新区块 ─────────────────────────────────────────────────────────────── */
type UpdateState =
@ -526,7 +694,7 @@ function UpdateSection() {
<h2 className="text-sm font-semibold text-gray-700 mb-4"> & </h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-700">Dev Manager</p>
<p className="text-sm text-gray-700"></p>
<p className="text-xs text-gray-400 mt-0.5"> v{appVersion}</p>
</div>
<div className="flex items-center gap-3">

View File

@ -908,6 +908,8 @@ export interface BlueprintData {
name: string;
description?: string;
updated?: string;
/** 当前迭代号,默认 1重构已完成模块时递增 */
iteration?: number;
areas: BlueprintArea[];
modules: BlueprintModule[];
edges: BlueprintEdge[];
@ -918,6 +920,93 @@ export interface BlueprintData {
export const getBlueprint = (projectPath: string) =>
invoke<BlueprintData | null>("get_blueprint", { projectPath });
export const generateBlueprintPrompt = (projectPath: string, promptType: "init" | "sync") =>
invoke<string>("generate_blueprint_prompt", { projectPath, promptType });
export interface BlueprintStatus {
status: "none" | "rules_outdated" | "content_stale" | "synced";
conventions_version?: string;
claude_md_managed: boolean;
claude_md_version?: string;
manifest_updated?: string;
master_version: string;
}
export interface SyncResult {
success: boolean;
conventions_written: boolean;
claude_md_action: string;
message: string;
}
export const getBlueprintStatus = (projectPath: string) =>
invoke<BlueprintStatus>("get_blueprint_status", { projectPath });
export const syncBlueprintRules = (projectPath: string) =>
invoke<SyncResult>("sync_blueprint_rules", { projectPath });
export interface FlywheelCohort {
conventions_version: string;
project_count: number;
/** 可派发率dispatched(可派发数) / total tasks最新快照均值 */
dispatchable_rate: number;
/** 任务完成率done / total tasks最新快照均值反映整体进度 */
completion_rate: number;
/** 飞轮持续率30天内有快照的项目比例 */
continuity_rate: number;
/** 阻塞密度blocked / total tasks最新快照均值 */
blocked_density: number;
}
export interface BlockedReasonCount {
reason: string;
count: number;
}
export interface StalledProject {
name: string;
/** UTC 日期字符串 "YYYY-MM-DD",未知时为 "unknown" */
last_active: string;
}
export interface IterationJump {
project: string;
from_iteration: number;
to_iteration: number;
/** 跳变前最后一条快照的任务完成率 */
rate_before: number;
/** 跳变后第一条快照的任务完成率 */
rate_after: number;
}
export interface FlywheelStats {
cohorts: FlywheelCohort[];
top_blocked_reasons: BlockedReasonCount[];
/** 飞轮停转项目60天无快照 */
stalled_projects: StalledProject[];
/** AI文档休眠项目90天无快照 */
ai_doc_stale_projects: StalledProject[];
/** 迭代跳变记录iteration 递增,说明发生了主动重构) */
iteration_jumps: IterationJump[];
/** 停滞模块in_progress 但无可推进任务,格式 "project/module_id" */
stalled_modules: string[];
total_projects_scanned: number;
projects_with_data: number;
}
export const getFlywheelStats = (projectPaths: string[]) =>
invoke<FlywheelStats>("get_flywheel_stats", { projectPaths });
export const generateMuhePrompt = (stats: FlywheelStats) =>
invoke<string>("generate_muhe_prompt", { stats });
export interface ArchiveResult {
archived_files: string[];
archive_dir: string;
}
export const archiveProjectDocs = (projectPath: string) =>
invoke<ArchiveResult>("archive_project_docs", { projectPath });
// ── Canvas State ───────────────────────────────────────────────────────────────
export const getCanvasState = (groupId: string) =>