}, // Web / 桌面多用户
+}
+impl Principal { pub fn local_owner() -> Self { Principal::LocalOwner } }
+```
+
+### 5.2 数据模型(新增迁移 `0081_users_auth.sql`,不改旧表)
+
+```sql
+CREATE TABLE users (
+ id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, display_name TEXT,
+ password_hash TEXT, -- 密码登录用;OIDC 可空
+ auth_provider TEXT NOT NULL DEFAULT 'local',
+ status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL);
+CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL);
+CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id, role_id));
+CREATE TABLE sessions ( -- 仅 Web 用;Tauri 不落
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
+ token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
+CREATE TABLE user_project_roles ( -- 团队内项目级 ACL(如外部顾问只接触指定项目),非多租户隔离
+ user_id TEXT, project_id TEXT, role TEXT, PRIMARY KEY(user_id, project_id));
+```
+
+### 5.3 权限点(RBAC,声明式)
+
+- 全局角色:`owner / admin / operator / reviewer / viewer`。
+- 关键动作绑权限点(写进 `#[command(perm="…")]`):
+ - `project.create` / `project.delete` / `project.write`
+ - `cr.review_1` / **`cr.review_2`**(审批合并——**安全铁律,必须 gated**,对应现有唯一合并入口)
+ - `settings.write` / `llm.key.write` / `agent.manage` / `intake.config`
+ - `issue.write` / `conversation.write`
+- 中央 guard:`dispatch` 在调 handler **前**统一 `authorize(&ctx.principal, def.perm)`,不过即拒(`AppError::Forbidden`)。
+- 资源级细判(「只能审自己项目的 CR」)在 handler 内用 `ctx.principal` 补充。
+
+```rust
+fn authorize(p: &Principal, perm: Option<&str>) -> Result<(), AppError> {
+ match (p, perm) {
+ (Principal::LocalOwner, _) => Ok(()), // 桌面 owner 全通
+ (_, None) => Ok(()), // 无标注=公开命令
+ (Principal::User{roles,..}, Some(pm)) =>
+ if roles.iter().any(|r| r.grants(pm)) { Ok(()) } else { Err(AppError::Forbidden) },
+ }
+}
+```
+
+### 5.4 项目级可见性(团队 ACL,非多租户隔离)
+
+现有查询全无 user 过滤。**在 core 数据访问处统一经 `scope(ctx)` 过滤器**,但目标场景是团队共享部署,默认应该是"全员可见团队内所有项目":
+- **默认**:`scope` = 恒等(全部可见),行为与今日桌面版完全一致——多数团队不需要更多。
+- **可选收紧**:某些项目需要限定访问的团队成员(如含敏感信息的项目、外部顾问只接触自己负责的项目)时,`scope` 按 `user_project_roles` 收窄可见集。
+- 统一走 `scope(ctx)` 这一个口子,是为了让"以后要不要加项目级限制"只改一处;**不是**为多租户 SaaS 预留隔离机制——那不是本设计的目标(见 §12)。
+
+### 5.5 审计
+
+`ctx.principal` 直接接入现有 `record_admin_decision*(… admin_id …)`(`change_requests.rs:1100` 等)——「谁批的合并/谁删的项目」自动留痕。`AppEvent` / 通知收件箱可加可选 `actor` 字段(`notification` 模型现无 actor,低优先补)。
+
+### 5.6 密钥与启动配置(headless)
+
+- 密钥保持**工厂级**(LLM/MCP key 全局,仅 `llm.key.write` gated);MVP **不做 per-user 密钥库**,避免过早复杂化。
+- 主密钥:`secrets.rs` 已支持 keyring→0600 文件回退,对接层启动时喂 `init_secrets(master_key_file)`:Tauri=`app_data_dir`,Web=配置/环境变量指定路径。
+- 启动需注入的路径(替代 `app.path()`):`app_data_dir`(→ `init_*_base`)、导出输出目录(替代 `download_dir`/`temp_dir`)。Web 头把「导出」改为 HTTP 下载后,输出目录仅用临时区。
+
+---
+
+## 6. 宿主能力抽象(收编 opener,其余无需抽象)
+
+评估确认宿主专有能力**只有 opener** 真正在用(notification/shell 死注册)。
+
+```rust
+#[async_trait]
+pub trait HostCapabilities: Send + Sync {
+ /// 打开外部 URL。Tauri: opener 插件;Web: 返回指令让前端 window.open。
+ async fn open_external(&self, url: &str) -> Result<(), AppError>;
+ /// 在文件管理器中定位/打开本地路径。Web 无此语义 → 返回 Unsupported,调用方降级。
+ async fn reveal_path(&self, path: &str) -> Result<(), AppError>;
+}
+```
+
+- `TauriHost`:`open_external`→`app.opener().open_url`(`demo.rs:4`);`reveal_path`→`reveal_item_in_dir`/`open_path`(`artifacts.rs:253`、`backup.rs:411`)。
+- `WebHost`:`open_external`→回结构化结果 `{action:"open_external",url}` 交前端执行;`reveal_path`→`Err(Unsupported)`(前端隐藏「在文件夹中显示」按钮)。
+- 现有 6 处 `OpenerExt` 直调统一改成 `ctx.host.*`。
+
+---
+
+## 7. 例外登记(两头行为必须不同的命令)
+
+> 这些是「改一处」原则的**已知合法例外**——因宿主语义本质不同而必须在两层分别落地。数量极少,单独列册以防蔓延。
+
+| 命令/能力 | Tauri 行为 | Web 行为 | 处理 |
+|-----------|-----------|----------|------|
+| `export_issues`(xlsx/csv,`issues.rs:451`) | 写入 `download_dir` 并可 reveal | `GET /blob/export_issues` 流式下载(`Content-Disposition`) | 迁到 `#[blob_command]`,core 产 `Blob{bytes,mime,filename}`,两层各自交付 |
+| intake 导出(`intake.rs:312`) | 同上 | 同上 | 同上 |
+| `reveal_path`(artifacts/backup) | 打开系统文件管理器定位 | 不支持 → 前端隐藏入口 | `HostCapabilities.reveal_path` 返回 `Unsupported` |
+| `open_url`(`demo.rs`) | 系统浏览器打开 | 前端 `window.open` | `HostCapabilities.open_external` |
+| 窗口控制(前端 `getCurrentWindow`) | 红绿灯/拖拽/最大化 | 隐藏,用浏览器边框 | `lib/window.ts` 按 `isTauri` 分支 |
+
+---
+
+## 8. 分阶段里程碑
+
+> 本节按 §12 的战略拆分重新组织为两条**可独立立项**的轨道:**Track 1(M0–M3,无条件推进)**架构重构,价值不依赖 Web 头是否上线、全程零回归;**Track 2(M4 起)**团队内网协作,需先过一道轻量 Gate 再启动——但目标场景是团队内网协作而非公网 SaaS,门槛远低于"做完 M3 自然滑入 M4"字面听起来的重量级决策。
+
+### Track 1 — 核心重构(现在做,无条件推进)
+
+| 里程碑 | 内容 | 依赖 | 验收 |
+|--------|------|------|------|
+| **M0** R1 | `trait EventSink` + `TauriSink`;49 处 `AppHandle` 透传替换 | — | `cargo build` 通过;桌面端事件行为与今日一致(通知收件箱不丢) |
+| **M1** R2+R6+workspace | `Ctx`/`#[command]` 宏/注册表;`HostCapabilities`;opener 收编;**顺手把 `crates/autoforge-core` 物理拆出**(§11,与其后补做不如一步到位) | M0 | 至少 1 个模块(如 `projects`)走注册表 dispatch,功能等价;`autoforge-core` 可独立 `cargo build` |
+| **M2** R3 | 分批把 312 命令迁到统一契约(灰度回落旧路径);**按功能域分批**,与该域的新功能开发错峰(§9 风险) | M1 | 每批迁移后 `tauri:dev` 全功能回归;`generate_handler!` 逐步清空 |
+| **M3** R4 | Tauri 对接层瘦身为单 dispatch | M2 | 桌面端全功能等价;对接层不含命令清单;`cargo tree -p autoforge-core \| grep tauri` 为空 |
+
+**先做且独立收益最大:M0**——即便永远不做 Web 头,它兑现了 CLAUDE.md「把 `AppHandle` 换成 `trait EventSink`」的既定愿景,减一层耦合、零回归、solo 维护者可独立完成。
+
+### Gate — 启动 Track 2 前过一遍(决策点,比表面听起来轻量)
+
+M3 完成后先回答这几条,而不是默认接着做 M4——但因为目标是**团队内网协作**、不是公网 SaaS,这道 Gate 不涉及融资/组建团队级别的决策,更多是"基础设施是否就绪"的落地问题(详细论证见 §12):
+
+1. 是否已有 ≥1 位真实同事/协作者明确表达过"希望不打开我的桌面客户端也能查看/操作 AutoForge",而不是自己假设团队需要?
+2. 团队内网是否有一台可以**常驻在线**的机器(办公室闲置主机、NAS、小型 Linux box)?—— 没有的话先解决这个前置问题,否则协作会因为"某人合上笔记本"而时断时续。
+3. 是否已用**形态 A**(同进程双头、局域网访问,把当前 Tauri 内嵌的 Web 头直接开放给同事试用几天)低成本验证过协作需求?—— 没做过,先做这一步,成本几乎为零。
+4. §5 的基本登录/会话/角色权限是否够用,还是团队已有 SSO/LDAP 需要对接?—— 内网场景通常前者就够,后者可作为 M5 之后的可选增强,不阻塞启动。
+
+前三条有清楚答案就可以推进 Track 2;第 4 条不阻塞——因为不涉及对外网开放,鉴权模型可以边用边加固。
+
+### Track 2 — 团队内网协作(条件推进,需求验证后启动)
+
+| 里程碑 | 内容 | 依赖 | 验收 |
+|--------|------|------|------|
+| **M4** R5+R7 | Web 头(可继续嵌在 Tauri 进程内,也可编成独立 headless 二进制)+ 前端传输/事件/窗口自适应;部署到团队内网常驻机器,团队通过 `http://<内网地址>:port` 访问 | Gate 通过 + M3 | 团队 2+ 人通过内网浏览器同时访问,跑通只读页面 + 一条写路径 + SSE 事件;服务持续在线,不依赖某个人笔记本不合盖 |
+| **M5** 团队协作角色 | 迁移 0081 + Principal 贯穿 + 声明式 authz;团队内按角色分工(谁能 `review_2` 批准合并、谁能删项目、谁能改 LLM key) | M4 | `cr.review_2` 需 reviewer 权限;越权请求被拒;**并发压测**(N 个账号同时提审/合并/发起会议室任务)不出竞态(见 §9 新增风险) |
+
+> 明确移出路线图:面向不特定客户的**多租户公有 SaaS**(含计费、租户隔离、7×24 公网值守)不是 AutoForge 的目标——目标是团队内网协作。若未来真出现"给外部客户远程部署"的诉求,那是性质完全不同、需要单独立项评估的新项目,不应该现在为它预先设计(YAGNI)。
+
+---
+
+## 9. 风险与保障
+
+| 风险 | 缓解 |
+|------|------|
+| 312 命令迁移量大、易漏 | M2 灰度回落旧路径,逐批迁移;每批跑 `tauri:dev` 回归;宏统一契约减少手写样板 |
+| 参数命名 camelCase↔snake_case 不一致 | dispatch 侧做一次归一;迁移期用类型化入参结构由 serde 兜底 |
+| M4→M5 过渡期:Web 头已在内网可达但登录/角色(M5)未落地,同网段任何人等效"全权 owner" | M4 阶段先只开放只读页面/团队内已知的少数写路径;`cr.review_2` 等敏感写路径优先跟 M5 一起上线,或过渡期用最简单的共享密码/token,不裸奔 |
+| 团队 ACL 配错导致越权读(§5.4) | `scope(ctx)` 默认恒等、收紧是唯一改点;配授权测试(非多租户隔离场景,风险面本身就小) |
+| `inventory`/`linkme` 跨平台/发布构建行为 | 早在 M1 于三端(Linux/macOS/Windows)发布构建各验一次收集是否完整 |
+| 破坏「合并唯一入口」安全铁律 | `cr.review_2` 强制 `perm` 门;authz guard 单测覆盖「无权不能触发 merge」 |
+| 团队服务器环境与桌面不一致(git/CLI 授权/工具链未装全) | M4 部署前对齐 checklist(对照桌面端依赖清单逐项装好),而非假设"跟本机一样" |
+| 并发写路径此前只被单用户使用过,未经真实多用户压测 | M5 验收纳入并发压测(N 账号同时提审/合并/建会议室任务),验证既有锁(merge_lock/cr_lock/conversation_lock)在真实并发下不出竞态 |
+| 内网可信 ≠ 零风险(同网段设备、访客 Wi-Fi 仍可能触达) | 即使内网也保留基本 session/token 鉴权(§5),不因为"内网"就完全裸奔 |
+| 常驻服务器的运维责任(重启/备份/磁盘空间)集中在少数人身上 | 量级远小于 SaaS 运维;沿用现有「配置备份」功能的使用习惯即可,不需要新增组织能力 |
+| 高迭代速度下 M2 与新功能并发抢 `commands/*.rs` | 按功能域分批迁移,迁移窗口内该域功能开发短暂错峰,而非全仓冻结 |
+| 文档滞后误导 AI Agent(CLAUDE.md/specs 语境仍是单机假设) | Track 2 启动同时同步更新 CLAUDE.md/specs 的部署语境假设,纳入 M4 验收范围,防止「AI 照旧文档把 Web 头改回单机语义」的系统性跑偏 |
+
+## 10. 验收标准(总)
+
+1. **薄对接层红线**:新增一个业务命令,两个对接层 diff = 0(仅 core 改动)。
+2. **桌面零回归**:M0–M3 每步 `npm run tauri:dev` 全功能等价。
+3. **双头对等**:M4 后同一前端在 Tauri 与浏览器下核心只读/写路径 + 事件流均可用。
+4. **权限有效**:M5 后 Web 未登录请求被拒;`cr.review_2` 等敏感动作按角色 gated;桌面 owner 行为无感。
+5. **无 Tauri 泄漏**:core crate 依赖图不含 `tauri`(`cargo tree -p autoforge-core | grep tauri` 为空)。
+6. **战略门禁**:M4 启动前必须有 §8 Gate 的书面答案,不得默认顺延(详见 §12)。
+
+---
+
+## 11. 代码组织:Workspace 拆分建议(而非仓库拆分)
+
+回答"是否推荐拆分"里的**工程/仓库维度**。
+
+**现状**:`src-tauri/Cargo.toml` 是单一 package,同时产出 `staticlib/cdylib/rlib`(供 Tauri)+ `bin`,未使用 Cargo workspace;`agents/core/tasks/models/db/state` 与 `commands/`(Tauri 命令层)混在同一 crate 内,靠人工审查/约定维持"业务不依赖 Tauri"(CLAUDE.md 已有此纪律,但纪律不是编译器)。
+
+**建议:现在就拆 Cargo workspace(同仓库多 crate),不拆 git 仓库(不做 polyrepo)。**
+
+拆 workspace 的理由:
+- §10 验收标准第 5 条「`cargo tree -p autoforge-core | grep tauri` 为空」**必须有独立 crate 才能验证**——同一个 package 内"模块间不该互相依赖"没有编译期强制力,只能靠人工 review,长期会腐化(正是"薄对接层"这个诉求本身想避免的模式:约定会被悄悄破坏,边界要靠编译器守,不能只靠文档)。
+- 与 R2(`#[command]` 宏/注册表)本就要抽 `Ctx` 同一批工作量,顺手把边界物理化,比"先在同 crate 里假装边界、以后再迁"更省一次返工。
+- 对 solo 维护者友好:`cargo check -p autoforge-core` 可独立跑,不必每次全量编译 Tauri 壳,日常迭代反而更快。
+
+不拆 git 仓库(不做 polyrepo)的理由:
+- solo 维护 + 高迭代速度下(§1.4),polyrepo 会引入版本对齐、跨仓库 PR 协调、CI 矩阵翻倍等开销,而目前没有第二个团队或"独立发布 core"的真实驱动力去承担这些成本。
+- Cargo workspace 的 crate 边界本身就是未来物理拆仓库的现成切割线——真到了「开源 core」或「Web 头交给独立团队」那天,直接把 `crates/autoforge-core` 平移出去即可;现在拆 polyrepo 反而是提前支付一笔用不上的税。
+
+**建议布局**(并入 M1,见 §8):
+```
+AutoForge/ (仍是单一 git 仓库)
+ Cargo.toml (新增:workspace 声明)
+ crates/
+ autoforge-core/ (agents/ core/ tasks/ models/ db/ state/ 从 src-tauri/src 平移)
+ autoforge-macros/ (#[command] proc-macro,见 §3.2)
+ src-tauri/ (瘦身:仅 dispatch + capabilities + tauri.conf.json,依赖 autoforge-core)
+ web/ (Track 2 才新增:axum 头,同样只依赖 autoforge-core)
+```
+
+---
+
+## 12. 战略决策:是否推荐拆分(长期价值判断)
+
+综合 §1.4 的组织现实评估,分三层给出明确建议:
+
+**(a) 架构拆分(core vs 对接层,即 Track 1)—— 推荐,无条件推进。**
+即使 Web 头永远不做,这也是 CLAUDE.md 早已确立的长期愿景("后端独立化")的落地:改善可测试性、消灭 `AppHandle` 透传耦合,且完全向后兼容、零业务风险,solo 维护者可独立完成,不需要新增任何组织能力。**这部分不是"要不要做"的问题,是"什么时候顺手做"的问题。**
+
+**(b) 代码/仓库拆分(workspace vs polyrepo)—— 推荐现在拆 workspace,不推荐拆仓库。** 见 §11,理由不重复。
+
+**(c) Web 头 + 团队内网协作(Track 2)—— 目标已明确为团队协作而非 SaaS,门槛显著低于最初评估,可以相对从容地推进,但仍建议先过轻量 Gate 再投入,避免为想象中的团队规模过度设计。**
+
+这个判断建立在一个关键澄清上:**AutoForge 的 Web 头要解决的是"团队内网协作",不是"面向不特定客户的多租户 SaaS"**。这个澄清直接消解了此前评估里最重的两块顾虑:
+
+1. **"本地能力远程化"不再是难题**——此前评估把它当作最大障碍,是因为设想了"每个客户的环境都不一样、要在我方服务器上逐一重建"(多租户场景)。团队内网协作只有**一套环境**:一台团队共享的常驻机器,装好 git/`claude` CLI 授权/编译工具链,这和"一个人的笔记本"在架构上是同一件事——只是从"一个 GUI 窗口"变成"多个浏览器连同一个后端"。真正要做的是 M4(部署到内网机器)+ M5(团队角色协作),不需要"重建 N 份隔离环境"那种量级的工程。
+2. **组织能力缺口大幅缩小**——公有 SaaS 需要的合规、计费、多租户隔离验证、7×24 公网值守,团队内网场景**全部不需要**。剩下的运维责任只是"服务器别宕机、有人记得备份",1 人团队完全扛得住,不需要等"融资/组建团队"。
+
+因此 Track 2 不再需要长期搁置——§8 的 Gate 已相应改写为轻量版(有没有常驻机器、有没有真实协作诉求、验证过没有、鉴权模型够不够用),都是可以在几天内低成本回答的问题,不是重量级商业决策。
+
+**具体行动建议:**
+1. **现在**:批准并执行 Track 1(M0–M3 + workspace 拆分)。纯正收益、可独立交付、无需新增组织能力。
+2. **Track 1 完成后**:过一遍 §8 的轻量 Gate——核心是"内网有没有一台常驻机器"和"真的有同事需要非桌面访问"这两件事,而不是"要不要做 SaaS"这种重决策。验证过就可以直接推进 M4 → M5,不需要无限期搁置。
+3. **保持在路线图之外**:多租户公有 SaaS(对外向不特定客户提供托管服务、需要计费与租户隔离)不是当前产品方向的自然延伸。若未来真出现这类诉求,那是一个需要从零单独评估的新项目,不应该现在为它预先设计(YAGNI)。
+
+一句话:**架构层面的"拆"(core/对接层/workspace)现在就该做,代价几乎为零;Track 2 一旦明确是"团队内网协作"而非"SaaS",也不再是需要长期观望的重决策——过一遍轻量 Gate(有没有常驻机器、有没有真实协作诉求)就可以推进,真正应该排除在路线图之外的只有"面向外部客户的多租户 SaaS"这一项。**
+
+---
+
+## 附录:本文引用的关键证据
+
+- 事件出口与副作用:`src-tauri/src/core/event.rs:166`、`:172`
+- AppState 纯字段:`src-tauri/src/state.rs:17`
+- 密钥回退:`src-tauri/src/core/secrets.rs:44`、`:113`、`:168`
+- 审批 admin_id:`src-tauri/src/commands/change_requests.rs:1100`
+- opener 用途:`demo.rs:4`、`artifacts.rs:253/258`、`backup.rs:411/416`
+- app.path 用途:`lib.rs:36`、`issues.rs:452`、`intake.rs:313`
+- 导出落盘:`issues.rs:299/451`、`intake.rs:312`
+- 前端单一入口:`src/services/index.ts:6`(唯一 invoke 出口)
+- listen 散点:`App.tsx` / `Dashboard.tsx` / `Projects.tsx` / `Settings.tsx` / `Conversations.tsx` / `Audit.tsx`
+- 迁移基线:`src-tauri/migrations/0080_batch_bind_source.sql`(下一号 0081)
+- 维护者规模:`git shortlog -sn --all`(`Renmengkai` 150 次人类提交;`AutoForge`/`autoforge@local` 39 次,经 `git log --author=AutoForge --format="%ae"` 核实为自动化 CR 流水线提交,非第二人类维护者);提交时间分布:`git log --format="%ad" --date=format:"%Y-%m"`(186 次集中于 2026-05~06)
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..9a3053d
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,9 @@
+AutoForge
+Copyright 2026 Vima Tech
+
+This product includes software developed by the AutoForge contributors.
+
+AutoForge and its visual identity are names and marks of Vima Tech. The
+Apache License 2.0 does not grant permission to use project trademarks except
+as required for reasonable and customary use in describing the origin of the
+software.
diff --git a/README.md b/README.md
index ee8178a..72beb8c 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,20 @@
让一个人管理一支 AI 工厂,把人的判断力留给真正重要的决策。
+
+
+
+
+
+
产品理念 ·
工作方式 ·
核心能力 ·
+ 下载 ·
快速开始 ·
- 技术架构
+ 技术架构 ·
+ 参与贡献
> [!NOTE]
@@ -77,11 +85,19 @@ flowchart LR
- **输入防护**:外部需求、网页与工具返回值按不可信数据处理,检测明显 Prompt Injection。
- **完整追踪**:Agent、模型与工具调用关联到同一条 trace,便于定位结果从何而来。
+## 下载
+
+前往 [GitHub Releases](https://github.com/vima-tech/AutoForge/releases/latest)
+下载 Windows、macOS 或 Linux 安装包。每个版本都附带 `SHA256SUMS.txt`,可用于校验下载文件完整性。
+
+> [!IMPORTANT]
+> AutoForge 目前是 Alpha 软件,能够调用本地编码 Agent、访问已配置仓库并连接外部模型或 MCP 服务。首次试用请使用可丢弃或已备份的仓库,并只授予必要凭据。
+
## 快速开始
### 前提条件
-- [Rust](https://rustup.rs/) 1.75+
+- [Rust](https://rustup.rs/) 1.88+
- Node.js 18+
- 已登录的本地 `claude` CLI,或在应用中配置可用的模型服务
- [Tauri 2 系统依赖](https://v2.tauri.app/start/prerequisites/)
@@ -120,9 +136,9 @@ xcode-select --install
### 本地运行
```bash
-git clone https://github.com/renmengkai/AutoForge.git
+git clone https://github.com/vima-tech/AutoForge.git
cd AutoForge
-npm install
+npm ci
npm run tauri:dev
```
@@ -160,6 +176,18 @@ specs/ Agent 与项目规格
docs/ 文档与品牌资产
```
+## 参与贡献
+
+欢迎提交 Bug、文档改进和聚焦的功能变更。请先阅读
+[贡献指南](CONTRIBUTING.md);安全问题请按 [安全策略](SECURITY.md)
+私下报告,不要创建公开 Issue。项目的集成分支是 `dev`,外部 Pull Request
+也应以 `dev` 为目标分支。
+
+## 开源协议
+
+AutoForge 基于 [Apache License 2.0](LICENSE) 开源。该协议允许使用、修改与分发,
+并包含明确的专利授权;项目名称和视觉标识不因代码许可而获得商标授权,详见 [NOTICE](NOTICE)。
+
---
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..34312a0
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security Policy
+
+## Supported versions
+
+AutoForge is alpha software. Security fixes are applied to the latest release
+and the `dev` branch; older releases are not supported.
+
+## Reporting a vulnerability
+
+Please do not open a public issue for a suspected vulnerability. Use
+[GitHub private vulnerability reporting](https://github.com/vima-tech/AutoForge/security/advisories/new)
+and include:
+
+- the affected version or commit;
+- reproduction steps or a proof of concept;
+- the expected impact;
+- any suggested mitigation, if known.
+
+Maintainers will acknowledge a complete report as soon as practical, keep you
+updated while it is investigated, and coordinate disclosure after a fix is
+available. Please avoid accessing data that is not yours or disrupting other
+systems while researching a report.
+
+## Security considerations
+
+AutoForge can invoke local coding-agent CLIs, access configured repositories
+and connect to user-supplied model or MCP services. Review integrations before
+enabling them, grant the minimum necessary credentials, and use disposable or
+backed-up repositories when evaluating alpha releases.
diff --git a/package-lock.json b/package-lock.json
index 0b7e408..0cb586d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "autoforge",
"version": "0.1.0",
+ "license": "Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
@@ -1761,9 +1762,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -1810,9 +1811,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -1830,7 +1831,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
diff --git a/package.json b/package.json
index c0420c6..8ecaa62 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,19 @@
{
"name": "autoforge",
"version": "0.1.0",
+ "description": "Human-gated autonomous software factory",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vima-tech/AutoForge.git"
+ },
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
+ "version:check": "node scripts/check-version.mjs",
"tauri": "tauri",
"tauri:dev": "node scripts/tauri-dev.mjs",
"tauri:build": "tauri build"
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index c0fd70f..d036712 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,5 +1,5 @@
# Rust 工具链锁定 —— 确保团队构建环境一致(rustup 自动按此安装/切换)
-# 项目使用 edition 2021,README 要求 Rust 1.75+。
+# 项目使用 edition 2021,README 要求 Rust 1.88+。
# 这里只声明 stable 通道(不钉死具体版本号),避免频繁的版本冲突与强制下载;
# 如需复现完全一致的构建,可将 channel 改为具体版本(如 "1.78.0")。
[toolchain]
diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs
new file mode 100644
index 0000000..386bf94
--- /dev/null
+++ b/scripts/check-version.mjs
@@ -0,0 +1,38 @@
+import { readFileSync } from 'node:fs';
+
+const packageVersion = JSON.parse(readFileSync('package.json', 'utf8')).version;
+const tauriVersion = JSON.parse(readFileSync('src-tauri/tauri.conf.json', 'utf8')).version;
+const cargoToml = readFileSync('src-tauri/Cargo.toml', 'utf8');
+const packageStart = cargoToml.indexOf('[package]');
+const packageEnd = cargoToml.indexOf('\n[', packageStart + '[package]'.length);
+const cargoPackage = packageStart >= 0
+ ? cargoToml.slice(packageStart, packageEnd >= 0 ? packageEnd : undefined)
+ : '';
+const cargoVersion = cargoPackage.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
+
+if (!cargoVersion) {
+ throw new Error('Could not read [package].version from src-tauri/Cargo.toml');
+}
+
+const versions = {
+ 'package.json': packageVersion,
+ 'src-tauri/Cargo.toml': cargoVersion,
+ 'src-tauri/tauri.conf.json': tauriVersion,
+};
+
+const uniqueVersions = new Set(Object.values(versions));
+if (uniqueVersions.size !== 1) {
+ const details = Object.entries(versions).map(([file, version]) => `${file}: ${version}`).join('\n');
+ throw new Error(`Application versions are out of sync:\n${details}`);
+}
+
+if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(packageVersion)) {
+ throw new Error(`Application version is not valid SemVer: ${packageVersion}`);
+}
+
+const tag = process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : undefined;
+if (tag && tag !== `v${packageVersion}`) {
+ throw new Error(`Release tag ${tag} does not match application version v${packageVersion}`);
+}
+
+console.log(`AutoForge version ${packageVersion} is consistent.`);
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 87ba40b..7603c83 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -334,6 +334,16 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "atoi_simd"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44"
+dependencies = [
+ "debug_unsafe",
+ "rustversion",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -657,17 +667,19 @@ dependencies = [
[[package]]
name = "calamine"
-version = "0.26.1"
+version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
+checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3"
dependencies = [
+ "atoi_simd",
"byteorder",
"codepage",
"encoding_rs",
+ "fast-float2",
"log",
- "quick-xml 0.31.0",
+ "quick-xml 0.41.0",
"serde",
- "zip",
+ "zip 8.6.0",
]
[[package]]
@@ -1045,9 +1057,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1213,6 +1225,12 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "debug_unsafe"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
+
[[package]]
name = "der"
version = "0.7.10"
@@ -1617,6 +1635,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fast-float2"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55"
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1656,6 +1680,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
+ "zlib-rs",
]
[[package]]
@@ -3863,13 +3888,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
- "quick-xml 0.39.4",
+ "quick-xml 0.41.0",
"serde",
"time",
]
@@ -4054,16 +4079,6 @@ version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
-[[package]]
-name = "quick-xml"
-version = "0.31.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
-dependencies = [
- "encoding_rs",
- "memchr",
-]
-
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -4075,10 +4090,11 @@ dependencies = [
[[package]]
name = "quick-xml"
-version = "0.39.4"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
+ "encoding_rs",
"memchr",
]
@@ -4477,7 +4493,7 @@ version = "0.79.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c743cb9f2a4524676020e26ee5f298445a82d882b09956811b1e78ca7e42b440"
dependencies = [
- "zip",
+ "zip 2.4.2",
]
[[package]]
@@ -6581,6 +6597,12 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -8230,6 +8252,26 @@ dependencies = [
"zopfli",
]
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap 2.14.0",
+ "memchr",
+ "typed-path",
+ "zopfli",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 2f54b74..79cba96 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -2,8 +2,11 @@
name = "autoforge"
version = "0.1.0"
description = "AutoForge — Human-Lite-in-the-Loop software factory"
-authors = []
+authors = ["Vima Tech"]
edition = "2021"
+license = "Apache-2.0"
+repository = "https://github.com/vima-tech/AutoForge"
+homepage = "https://github.com/vima-tech/AutoForge"
[lib]
name = "autoforge_lib"
@@ -67,7 +70,7 @@ rand = "0.8"
# 物料库删除走系统回收站(freedesktop XDG Trash / macOS Trash / Windows 回收站),便于误删恢复。
trash = "5"
# 批量导入读取电子表格(xlsx/xls/ods,纯 Rust,只读);模板导出写 xlsx(rust_xlsxwriter,纯 Rust)。
-calamine = "0.26"
+calamine = "0.36"
rust_xlsxwriter = "0.79"
# Linux/WebKitGTK:直接访问底层 webview 以放行 getUserMedia(麦克风/语音录入)。
diff --git a/src-tauri/migrations/0081_context_index.sql b/src-tauri/migrations/0081_context_index.sql
new file mode 100644
index 0000000..69ddd49
--- /dev/null
+++ b/src-tauri/migrations/0081_context_index.sql
@@ -0,0 +1,32 @@
+-- 上下文基质 L2:ContextItem 薄索引(方法论平台/基质设计 §3.1、§6)。
+--
+-- 目的:把当前「游离在上下文之外」的一切物料 + 过程信息(物料库、编码 Agent 日志、
+-- llm_trace、会议室消息、CR 审核意见、孵化台草稿……)统一投影为「可被任意环节按需
+-- 取用的上下文条目」的元数据索引。正文永远回原表/文件懒取(content_ref 定位),
+-- 本表**不存正文**——是读侧统一视图 + 一层薄索引,不破坏任何既有存储与迁移。
+--
+-- 铁律:纯新增表,不改任何既有表语义(对齐「迁移只增不改」)。各来源写入时顺带登记
+-- (register),或后台投影任务补齐。trust 决定回灌上下文前是否过 has_obvious_injection。
+CREATE TABLE IF NOT EXISTS context_index (
+ id TEXT PRIMARY KEY, -- 稳定引用:: 派生,跨会话/跨阶段有效
+ project_id TEXT NOT NULL, -- 归属项目(跨项目隔离边界)
+ source_kind TEXT NOT NULL, -- file_priority/workspace_doc/material/chat_message/code_agent_log/llm_trace/incubator_draft/...
+ source_id TEXT NOT NULL, -- 原表主键 / 文件相对路径
+ title TEXT NOT NULL DEFAULT '', -- 人可读标题
+ origin_stage TEXT NOT NULL DEFAULT '', -- requirement/design/chat/coding/review/ops
+ origin_actor TEXT NOT NULL DEFAULT '', -- user / agent-id / system
+ content_ref TEXT NOT NULL DEFAULT '', -- 正文定位器:file: / table:.# / lazy::
+ size_hint INTEGER NOT NULL DEFAULT 0, -- 体积(字节,装配预算用)
+ trust TEXT NOT NULL DEFAULT 'trusted',-- trusted / external_untrusted(外部来源必过注入闸)
+ labels TEXT NOT NULL DEFAULT '[]', -- 自由标签 JSON 数组(检索 / 取景框过滤)
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- 按项目 + 来源类型 + 时间倒序枚举候选(装配引擎的主查询路径)。
+CREATE INDEX IF NOT EXISTS idx_context_index_project
+ ON context_index (project_id, source_kind, created_at DESC);
+
+-- 按原始来源定位(投影登记时的 upsert / 反查)。
+CREATE INDEX IF NOT EXISTS idx_context_index_source
+ ON context_index (source_kind, source_id);
diff --git a/src-tauri/migrations/0082_incubator_deepen.sql b/src-tauri/migrations/0082_incubator_deepen.sql
new file mode 100644
index 0000000..3e73217
--- /dev/null
+++ b/src-tauri/migrations/0082_incubator_deepen.sql
@@ -0,0 +1,14 @@
+-- 孵化台深化(孵化台深化/设计方案 §5、§6.1):为「带控制通道的 Agent 起草会话」预留数据位。
+-- 纯新增列,不改既有语义(迁移只增不改)。内部命名继续留 blueprint_*(避免无谓 churn)。
+--
+-- eval_json —— 每次落稿后 critic 的四维打分 + 待补强项(P3 默认多轮评估)
+-- pending_question —— ask_user 挂起时的待答问题文本(+可选 options)(P2 断点续跑)
+-- context_json —— 每稿的上下文账本:pinned 文件 / codegraph 命中 / 已答决策 /
+-- 工具产出摘要 / eval 结论。结构对齐基质 ContextItem 数组(§4 兜底),
+-- 便于日后零改造收编进 context_index。
+ALTER TABLE blueprint_drafts ADD COLUMN eval_json TEXT NOT NULL DEFAULT '';
+ALTER TABLE blueprint_drafts ADD COLUMN pending_question TEXT NOT NULL DEFAULT '';
+ALTER TABLE blueprint_drafts ADD COLUMN context_json TEXT NOT NULL DEFAULT '';
+
+-- status 值域新增 'awaiting_answer'(待答复派生态)——自由字符串列,无需 DDL。
+-- blueprint_messages.role 值域新增 'question'/'answer'/'eval'/'tool'——自由字符串,无需 DDL。
diff --git a/src-tauri/migrations/0083_prototype_per_requirement.sql b/src-tauri/migrations/0083_prototype_per_requirement.sql
new file mode 100644
index 0000000..516958b
--- /dev/null
+++ b/src-tauri/migrations/0083_prototype_per_requirement.sql
@@ -0,0 +1,8 @@
+-- 原型设计从「全项目一个」升级为「按需求(孵化台草稿)」的实用功能:
+-- draft_id —— 绑定生成该原型的孵化台大需求草稿(一键从孵化台跳入 + 按需求过滤展示)
+-- design_mode —— 'new'(新页面)/ 'existing'(在现有页面基础上改动),供 UI 标注与追溯
+-- 纯新增列,不改既有语义(旧数据 draft_id/design_mode 为空,行为=项目级原型,向后兼容)。
+ALTER TABLE prototype_prompts ADD COLUMN draft_id TEXT NOT NULL DEFAULT '';
+ALTER TABLE prototype_prompts ADD COLUMN design_mode TEXT NOT NULL DEFAULT '';
+
+CREATE INDEX IF NOT EXISTS idx_prototype_prompts_draft ON prototype_prompts(draft_id);
diff --git a/src-tauri/migrations/0084_prototype_requires_draft.sql b/src-tauri/migrations/0084_prototype_requires_draft.sql
new file mode 100644
index 0000000..52584bb
--- /dev/null
+++ b/src-tauri/migrations/0084_prototype_requires_draft.sql
@@ -0,0 +1,4 @@
+-- 原型提示词必须对应一个孵化台需求(draft)。
+-- 历史遗留的、未关联任何需求(draft_id 为空/NULL)的旧原型提示词一次性清理删除,
+-- 使 prototype_prompts 中每一条都归属某个 blueprint_draft。此后生成入口在后端硬拒空 draft_id。
+DELETE FROM prototype_prompts WHERE draft_id IS NULL OR TRIM(draft_id) = '';
diff --git a/src-tauri/migrations/0085_conversation_soft_delete.sql b/src-tauri/migrations/0085_conversation_soft_delete.sql
new file mode 100644
index 0000000..b099c6b
--- /dev/null
+++ b/src-tauri/migrations/0085_conversation_soft_delete.sql
@@ -0,0 +1,4 @@
+-- 会议室对话软删除:右键删除对话时只标记 deleted_at,不物理删除记录与历史消息。
+-- 列表查询过滤 deleted_at IS NULL;软删除的对话对用户隐藏但数据保留、可后续恢复/审计。
+ALTER TABLE conversations ADD COLUMN deleted_at TEXT;
+CREATE INDEX IF NOT EXISTS ix_conversations_deleted ON conversations(deleted_at);
diff --git a/src-tauri/migrations/0086_message_soft_delete.sql b/src-tauri/migrations/0086_message_soft_delete.sql
new file mode 100644
index 0000000..b05a5f4
--- /dev/null
+++ b/src-tauri/migrations/0086_message_soft_delete.sql
@@ -0,0 +1,4 @@
+-- 会议室单条消息软删除:右键消息气泡删除时只标记 deleted_at,不物理删除。
+-- 列表/未读/预览查询均过滤 deleted_at IS NULL;消息本体保留、可后续恢复或审计。
+ALTER TABLE messages ADD COLUMN deleted_at TEXT;
+CREATE INDEX IF NOT EXISTS ix_messages_deleted ON messages(conversation_id, deleted_at);
diff --git a/src-tauri/migrations/0087_cpu_budget_default_on.sql b/src-tauri/migrations/0087_cpu_budget_default_on.sql
new file mode 100644
index 0000000..efde738
--- /dev/null
+++ b/src-tauri/migrations/0087_cpu_budget_default_on.sql
@@ -0,0 +1,12 @@
+-- 并发调度按核预算 P1:cgroup CPU 硬兜底默认开启。
+--
+-- 背景:核预算硬兜底(core/cpubudget.rs)代码早已实现且 code agent 进程组已 attach,但
+-- 默认 `execution.cpu_budget_pct=0` 关闭 → N 个并行 claude -p 内部 rustc/tsc 突发无硬封顶,
+-- 唯一即时生效的只有事后 loadavg 闸,导致并发编码时 CPU 占满。
+--
+-- 存量 DB 里若曾保存过设置就写入了 '0'(改 Rust 默认常量对这些行无效)。这里把「明确等于 0」
+-- 的存量值一次性重置为 90,与新默认对齐。大概率 0 是「从未调过」而非「有意关闭」;用户仍可在
+-- 「并发控制」UI 改回 0 显式关闭。仅重置值恰为 '0' 的行,不动其它显式设定值。
+UPDATE app_settings
+ SET value = '90', updated_at = datetime('now')
+ WHERE key = 'execution.cpu_budget_pct' AND value = '0';
diff --git a/src-tauri/migrations/0088_retire_build_slots.sql b/src-tauri/migrations/0088_retire_build_slots.sql
new file mode 100644
index 0000000..9c7c650
--- /dev/null
+++ b/src-tauri/migrations/0088_retire_build_slots.sql
@@ -0,0 +1,6 @@
+-- 并发调度按核预算 P2:退役旧「构建池」设置键。
+--
+-- `execution.build_slots`(1 CR = 1 permit 的 CR 计数)已被 `execution.cpu_permits`(按核加权
+-- 令牌上限)取代,语义不同——旧值(默认 2)在核加权语义下等于只给 2 核、限流过紧,故**不迁移
+-- 旧值**,直接删除废弃键,让 `load_cpu_permits` 回落到「无值 = nproc」的新默认,自动贴合机器核数。
+DELETE FROM app_settings WHERE key = 'execution.build_slots';
diff --git a/src-tauri/src/agents/code_agent/mod.rs b/src-tauri/src/agents/code_agent/mod.rs
index 7daea6d..98ea663 100644
--- a/src-tauri/src/agents/code_agent/mod.rs
+++ b/src-tauri/src/agents/code_agent/mod.rs
@@ -182,6 +182,41 @@ pub async fn log_run(db: &crate::db::Db, input: RunLogInput<'_>) {
tracing::warn!("code agent run log insert failed: {e}");
return;
}
+
+ // 上下文基质登记(基质设计 §2.2 关键缺口:编码 Agent 执行日志此前游离在上下文之外)。
+ // 把本次执行日志投影为 ContextItem,让后续环节/其他 Agent 可引用「上次编码怎么跑的」。
+ // best-effort:查 CR 归属项目后登记;content_ref=clog: 对应 fetch_content 的 clog 读取器。
+ if let Some((project_id,)) =
+ sqlx::query_as::<_, (String,)>("SELECT project_id FROM change_requests WHERE id=?")
+ .bind(input.change_request_id)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ {
+ let cref = format!("clog:{id}");
+ let title = format!(
+ "编码执行日志 · {} · {}(exit {})",
+ input.kind, input.phase, input.exit_code
+ );
+ let _ = crate::core::context::register(
+ db,
+ crate::core::context::NewContextItem {
+ project_id: &project_id,
+ source_kind: crate::core::context::source_kind::CODE_AGENT_LOG,
+ source_id: &id,
+ title: &title,
+ origin_stage: "coding",
+ origin_actor: input.kind,
+ content_ref: &cref,
+ size_hint: stdout_bytes + stderr_bytes,
+ trust: crate::core::context::trust::TRUSTED,
+ labels: "[]",
+ },
+ )
+ .await;
+ }
+
// 滚动清理:超过保留窗口的日志删除(带索引,开销极低)。
let _ = sqlx::query("DELETE FROM code_agent_run_logs WHERE created_at < datetime('now', ?)")
.bind(format!("-{LOG_RETENTION_DAYS} days"))
diff --git a/src-tauri/src/agents/roles.rs b/src-tauri/src/agents/roles.rs
index d287a4e..4f54304 100644
--- a/src-tauri/src/agents/roles.rs
+++ b/src-tauri/src/agents/roles.rs
@@ -124,6 +124,8 @@ const PROMPT_MATERIAL_AI: &str = "你是 AutoForge 的物料库 AI 助手,服
const PROMPT_SPEC_WRITER: &str = "你是 AutoForge 的项目规格生成 Agent,依据项目信息、技术文件摘要与物料列表,产出可执行、可维护的结构化规格约束(技术栈、架构边界、编码规范、API 契约、测试要求等)。\n\n原则:规格要具体、可校验、可落地,避免空泛形容词;与项目实际技术栈和既有约定保持一致;条目之间不矛盾;只基于给定材料,信息不足处标注而非臆造。\n\n严格遵守调用方在本次请求中指定的输出格式:要求 JSON 时只输出 JSON(不加 Markdown 代码围栏、不加解释),字段名与结构必须与要求完全一致。";
+const PROMPT_SPEC_GRADER: &str = "你是 AutoForge 孵化台的蓝图评审 Agent(critic)。给定一份大需求蓝图(PRD + 规格 + 任务清单)与项目上下文,从四个维度打分并指出待补强项,供起草 Agent 据此自动修订。\n\n四维(各 0-10 整数):prd_completeness(PRD 完整度:背景/目标/验收是否齐全可校验)、spec_executability(规格可执行性:是否具体、可落地、无空泛形容词)、task_granularity(任务粒度:拆分是否合理、可独立交付)、code_fit(代码贴合度:是否贴合项目真实技术栈与既有约定)。\n\n只输出如下 JSON,不加任何额外文字或代码围栏:\n{\"prd_completeness\":8,\"spec_executability\":7,\"task_granularity\":6,\"code_fit\":7,\"gaps\":[\"待补强项1\",\"待补强项2\"],\"summary\":\"一句话总评\"}";
+
const PROMPT_GRADER: &str = "你是 AutoForge 的代码风险分级器。基于代码 diff 评估合并到主干的风险等级,用于决定能否门控自动放行。\n\n分级标准(就高不就低,命中更高档即取更高档):\n- T0 零风险:文档、注释、格式化、纯测试改动,无运行时行为变化。\n- T1 低风险:局部、隔离的小逻辑改动,爆炸半径限于单文件/单函数,易回滚。\n- T2 中风险:常规业务逻辑、跨多文件或模块的功能改动。\n- T3 高风险:数据库 schema/迁移、鉴权与权限、支付与资金、安全相关、依赖变更、公共接口契约变更,或爆炸半径大、难回滚的改动。\n\n判定要点:关注影响面、可逆性、对数据与安全的触及;不确定时从高判定。\n\n只输出一个等级标识:T0、T1、T2 或 T3。不要输出任何解释、标点或其它文字。";
const PROMPT_SECURITY: &str = "你是 AutoForge 的安全审计 Agent。审查代码 diff,只报告**真实可利用**的安全问题,不报风格或一般质量问题、不臆测。\n\n重点检测:硬编码密钥/凭证泄露、SQL/命令/模板注入、鉴权与越权缺陷、不安全反序列化、路径穿越、SSRF、危险或已知漏洞依赖、敏感信息明文存储或写日志、未校验的外部输入、不安全的随机数与加密误用。\n\n严重级别:critical(可直接远程利用或凭证泄露)、high(明确漏洞需尽快修)、medium(条件触发或影响有限)、low(加固建议)。\n\n严格输出 JSON 数组,每项形如 {\"severity\":\"low|medium|high|critical\",\"title\":\"问题简述\",\"detail\":\"成因、位置与修复建议\"};确无问题时输出 []。只输出 JSON 数组,不要解释或代码围栏。";
@@ -177,6 +179,9 @@ pub static ROLE_REGISTRY: &[RoleDef] = &[
RoleDef { kind: "spec_writer", name: "规格生成器", name_en: "Spec Writer", group: RoleGroup::Delivery, binding: RoleBinding::SystemKind,
builtin_prompt: PROMPT_SPEC_WRITER, default_caps: "[\"spec\",\"technical_constraints\"]", color: "#4f8ed1", icon: "file", initial: "规",
desc: "驱动项目规格页 AI 一键生成技术约束", usage: "项目 · 规格页 AI 一键生成", default_chat: false, llm_only: false },
+ RoleDef { kind: "spec_grader", name: "蓝图评审", name_en: "Spec Grader", group: RoleGroup::Delivery, binding: RoleBinding::SystemKind,
+ builtin_prompt: PROMPT_SPEC_GRADER, default_caps: "[\"spec\",\"review\"]", color: "#e0a32e", icon: "flask", initial: "评",
+ desc: "孵化台 critic:四维给蓝图打分 + 列待补强项,驱动多轮自评修订", usage: "孵化台 · 落稿后自动评估(P3)", default_chat: false, llm_only: true },
// ── 需求流水线(forge_role)──
RoleDef { kind: "analysis", name: "需求分析师", name_en: "Analyst", group: RoleGroup::Pipeline, binding: RoleBinding::ForgeRole,
builtin_prompt: crate::agents::analysis::SYSTEM_PROMPT, default_caps: "[\"analysis\",\"triage\"]", color: "#8b7ad8", icon: "search", initial: "析",
diff --git a/src-tauri/src/agents/tools/context_recall.rs b/src-tauri/src/agents/tools/context_recall.rs
new file mode 100644
index 0000000..9e69b1c
--- /dev/null
+++ b/src-tauri/src/agents/tools/context_recall.rs
@@ -0,0 +1,350 @@
+//! 上下文基质取用工具(消费侧)——让 Agent 按需从统一基质拉取「之前任意环节创建的
+//! 物料 / 过程信息」(需求、编码执行日志、孵化台草稿、会议室发言、审核意见……)。
+//!
+//! 这是方法论平台原则二「任何环节都能取用之前创建的一切上下文」的**消费侧兑现**:
+//! 基质 register 钩子在各写入路径沉淀条目(issue/clog/bp/atr/crv),本工具让 Agent 主动取用。
+//!
+//! 只读、无副作用(CLAUDE.md「MVP 只读工具」铁律)。持 db + project_id(来自 [`ToolContext`]),
+//! 不引用任何 Tauri 类型。返回内容由 [`super::ToolRegistry::invoke`] 统一过注入闸 + 截断。
+
+use anyhow::Result;
+use async_trait::async_trait;
+use serde_json::{json, Value};
+use std::sync::Arc;
+
+use super::{BuiltinTool, Tool, ToolContext, ToolInfo, ToolSpec};
+use crate::core::context;
+use crate::db::Db;
+
+/// `recall_context` 工具工厂:无项目时 `build` 返回 None(基质按项目隔离)。
+pub struct RecallContextFactory;
+
+#[async_trait]
+impl BuiltinTool for RecallContextFactory {
+ fn info(&self) -> ToolInfo {
+ ToolInfo {
+ name: "recall_context",
+ label: "取用上下文基质",
+ needs_project: true,
+ }
+ }
+
+ async fn build(&self, db: &Db, ctx: &ToolContext) -> Option> {
+ let project_id = ctx.project_id.clone()?;
+ Some(Arc::new(RecallContextTool {
+ db: db.clone(),
+ project_id,
+ }) as Arc)
+ }
+}
+
+struct RecallContextTool {
+ db: Db,
+ project_id: String,
+}
+
+#[async_trait]
+impl Tool for RecallContextTool {
+ fn spec(&self) -> ToolSpec {
+ ToolSpec::new(
+ "recall_context",
+ "从本项目的统一「上下文基质」按需取用之前任意环节沉淀的物料 / 过程信息\
+ (需求 issue、编码执行日志 code_agent_log、孵化台草稿 incubator_draft、\
+ 会议室 Agent 发言 agent_output、审核意见 cr_review 等)。返回按来源类型分组的\
+ 候选条目 + 正文摘要,供你判断哪些与当前任务相关。可用 query 关键词检索\
+ 全部历史(不限于最近条目);需要全文时再用 read_context(id)。",
+ json!({
+ "type": "object",
+ "properties": {
+ "kinds": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "按来源类型过滤(可选):issue / code_agent_log / incubator_draft / agent_output / cr_review 等;留空 = 不限来源"
+ },
+ "query": {
+ "type": "string",
+ "description": "标题关键词(可选):检索全部历史来源;留空 = 按时间取最近"
+ },
+ "limit": { "type": "integer", "description": "最多返回条数(默认 8,上限 30)" }
+ }
+ }),
+ )
+ }
+
+ async fn call(&self, args: Value) -> Result {
+ let kinds: Vec = args
+ .get("kinds")
+ .and_then(|v| v.as_array())
+ .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
+ .unwrap_or_default();
+ let kr: Vec<&str> = kinds.iter().map(|s| s.as_str()).collect();
+ let query = args.get("query").and_then(|v| v.as_str()).map(str::trim).filter(|q| !q.is_empty());
+ let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(8).clamp(1, 30);
+
+ let items = context::list(&self.db, &self.project_id, &kr, limit, query).await?;
+ if items.is_empty() {
+ return Ok("(基质中暂无匹配的上下文条目)".to_string());
+ }
+ let mut out = String::new();
+ for it in &items {
+ // 每条附一段短摘要(大体量来源自动走保尾摘要),便于 Agent 判断相关性;
+ // 需要全文时用 read_context(id) 取用。
+ let snippet = context::fetch_content(&self.db, it, 400)
+ .await
+ .unwrap_or_default();
+ let snippet = snippet.trim();
+ out.push_str(&format!(
+ "- [{}] {}({})\n {}\n",
+ it.source_kind,
+ it.title,
+ it.id,
+ if snippet.is_empty() { "(无正文)" } else { snippet }
+ ));
+ }
+ Ok(out)
+ }
+}
+
+/// `read_context` 工具工厂:recall 的姊妹工具,按条目 id 取全文(pull 闭环的最后一块)。
+pub struct ReadContextFactory;
+
+#[async_trait]
+impl BuiltinTool for ReadContextFactory {
+ fn info(&self) -> ToolInfo {
+ ToolInfo {
+ name: "read_context",
+ label: "读取上下文全文",
+ needs_project: true,
+ }
+ }
+
+ async fn build(&self, db: &Db, ctx: &ToolContext) -> Option> {
+ let project_id = ctx.project_id.clone()?;
+ Some(Arc::new(ReadContextTool {
+ db: db.clone(),
+ project_id,
+ }) as Arc)
+ }
+}
+
+struct ReadContextTool {
+ db: Db,
+ project_id: String,
+}
+
+#[async_trait]
+impl Tool for ReadContextTool {
+ fn spec(&self) -> ToolSpec {
+ ToolSpec::new(
+ "read_context",
+ "按条目 id 读取上下文基质里一条内容的全文(id 来自 recall_context 返回的括号内标识,\
+ 形如 issue:xxx / code_agent_log:xxx)。用于 recall 的 400 字摘要不够判断时深入阅读。",
+ json!({
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "description": "条目 id(:)" },
+ "max_chars": { "type": "integer", "description": "最多返回字符数(默认 4000,范围 500–8000)" }
+ },
+ "required": ["id"]
+ }),
+ )
+ }
+
+ async fn call(&self, args: Value) -> Result {
+ let id = args
+ .get("id")
+ .and_then(|v| v.as_str())
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
+ let max_chars = args
+ .get("max_chars")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(4000)
+ .clamp(500, 8000) as usize;
+
+ let Some(item) = context::get(&self.db, id).await? else {
+ return Ok(format!("(条目 {id} 不存在)"));
+ };
+ // 【安全】项目归属校验:DB 来源的 fetch 不带项目过滤,缺这道就是跨项目读洞。
+ // 缓存命中的条目带归属,直接比对;get() 缓存 miss 反构的条目归属为空,
+ // 走 provider 声明的 scope 活查补验(文件/全局来源天然安全,见 belongs_to_project)。
+ let owned = if item.project_id.is_empty() {
+ crate::core::context_providers::belongs_to_project(
+ &self.db,
+ &item.source_kind,
+ &item.source_id,
+ &self.project_id,
+ )
+ .await
+ .unwrap_or(false)
+ } else {
+ item.project_id == self.project_id
+ };
+ if !owned {
+ return Ok("(该条目不属于当前项目,已拒绝读取)".to_string());
+ }
+ let body = context::fetch_content(&self.db, &item, max_chars).await?;
+ // 【安全】外部不可信来源双保险:fetch 链路已过注入闸,此处再兜一道
+ // (防未来某 provider 的 fetch 路径漏接闸)。
+ if item.trust == context::trust::EXTERNAL_UNTRUSTED
+ && crate::core::security::has_obvious_injection(&body)
+ {
+ return Ok("(外部来源疑似注入,已拦截)".to_string());
+ }
+ if body.trim().is_empty() {
+ return Ok(format!("(条目 {id} 无正文)"));
+ }
+ Ok(body)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::context::{register, source_kind, NewContextItem};
+
+ async fn pool() -> Db {
+ let p = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE context_index (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
+ origin_stage TEXT NOT NULL DEFAULT '', origin_actor TEXT NOT NULL DEFAULT '',
+ content_ref TEXT NOT NULL DEFAULT '', size_hint INTEGER NOT NULL DEFAULT 0,
+ trust TEXT NOT NULL DEFAULT 'trusted', labels TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')))",
+ )
+ .execute(&p)
+ .await
+ .unwrap();
+ p
+ }
+
+ #[tokio::test]
+ async fn recall_lists_registered_items() {
+ let db = pool().await;
+ register(
+ &db,
+ NewContextItem::trusted("p1", source_kind::ISSUE, "i1", "登录页需求", ""),
+ )
+ .await
+ .unwrap();
+ register(
+ &db,
+ NewContextItem::trusted("p1", source_kind::CR_REVIEW, "cr1", "审核意见 · 代码审核", ""),
+ )
+ .await
+ .unwrap();
+
+ let tool = RecallContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ let out = tool.call(json!({})).await.unwrap();
+ assert!(out.contains("登录页需求"));
+ assert!(out.contains("审核意见"));
+ assert!(out.contains("issue:i1"));
+
+ // 按来源过滤:只要 cr_review。
+ let only = tool.call(json!({"kinds": ["cr_review"]})).await.unwrap();
+ assert!(only.contains("审核意见"));
+ assert!(!only.contains("登录页需求"));
+ }
+
+ #[tokio::test]
+ async fn recall_empty_project_is_graceful() {
+ let db = pool().await;
+ let tool = RecallContextTool {
+ db,
+ project_id: "empty".into(),
+ };
+ let out = tool.call(json!({})).await.unwrap();
+ assert!(out.contains("暂无匹配"));
+ }
+
+ /// recall 的 query 参数:关键词只召回命中标题的条目。
+ #[tokio::test]
+ async fn recall_query_filters_by_title() {
+ let db = pool().await;
+ register(&db, NewContextItem::trusted("p1", source_kind::ISSUE, "i1", "登录页需求", ""))
+ .await
+ .unwrap();
+ register(&db, NewContextItem::trusted("p1", source_kind::ISSUE, "i2", "支付流程", ""))
+ .await
+ .unwrap();
+ let tool = RecallContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ let out = tool.call(json!({"query": "支付"})).await.unwrap();
+ assert!(out.contains("支付流程"));
+ assert!(!out.contains("登录页需求"));
+ }
+
+ /// read_context:缓存命中的本项目条目可读(无活表时正文回落标题)。
+ #[tokio::test]
+ async fn read_context_reads_owned_item() {
+ let db = pool().await;
+ register(&db, NewContextItem::trusted("p1", source_kind::ISSUE, "i1", "登录页需求", ""))
+ .await
+ .unwrap();
+ let tool = ReadContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ let out = tool.call(json!({"id": "issue:i1"})).await.unwrap();
+ assert!(out.contains("登录页需求"), "正文(此处回落标题)应可读: {out}");
+ }
+
+ /// 【安全】read_context 跨项目拒绝:别项目的条目(缓存带归属)读不到。
+ #[tokio::test]
+ async fn read_context_rejects_foreign_project_item() {
+ let db = pool().await;
+ register(&db, NewContextItem::trusted("p2", source_kind::ISSUE, "x9", "别家项目机密需求", ""))
+ .await
+ .unwrap();
+ let tool = ReadContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ let out = tool.call(json!({"id": "issue:x9"})).await.unwrap();
+ assert!(out.contains("不属于当前项目"));
+ assert!(!out.contains("机密"));
+ }
+
+ /// 【安全】read_context 归属不可证时默认拒绝:缓存 miss 反构的条目(project_id 空)
+ /// 走 belongs_to_project 活查,活表缺失/未命中 → 拒绝而非放行。
+ #[tokio::test]
+ async fn read_context_denies_unverifiable_ownership() {
+ let db = pool().await;
+ let tool = ReadContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ // issue 是已知 kind → get() 反构成功(归属为空),但 memory 库无 issues 表 → 校验失败 → 拒绝。
+ let out = tool.call(json!({"id": "issue:ghost"})).await.unwrap();
+ assert!(out.contains("不属于当前项目"));
+ }
+
+ /// read_context 参数护栏:缺 id 报错、max_chars 越界被 clamp(不 panic 不报错)。
+ #[tokio::test]
+ async fn read_context_arg_guards() {
+ let db = pool().await;
+ register(&db, NewContextItem::trusted("p1", source_kind::ISSUE, "i1", "登录页需求", ""))
+ .await
+ .unwrap();
+ let tool = ReadContextTool {
+ db,
+ project_id: "p1".into(),
+ };
+ assert!(tool.call(json!({})).await.is_err(), "缺 id 应报错");
+ let out = tool.call(json!({"id": "issue:i1", "max_chars": 999999})).await.unwrap();
+ assert!(out.contains("登录页需求"));
+ }
+}
diff --git a/src-tauri/src/agents/tools/mod.rs b/src-tauri/src/agents/tools/mod.rs
index 20b20a8..8a51544 100644
--- a/src-tauri/src/agents/tools/mod.rs
+++ b/src-tauri/src/agents/tools/mod.rs
@@ -18,6 +18,7 @@ use crate::core::security::{has_obvious_injection, safe_truncate};
pub mod code_intel;
pub mod code_scan;
+pub mod context_recall;
pub mod deep_research;
pub mod mcp;
pub mod memory;
@@ -235,6 +236,8 @@ pub fn builtin_catalog() -> Vec> {
Box::new(code_scan::CodeScanFactory::List),
Box::new(specs::SpecToolFactory::List),
Box::new(specs::SpecToolFactory::Read),
+ Box::new(context_recall::RecallContextFactory),
+ Box::new(context_recall::ReadContextFactory),
Box::new(memory::MemoryToolFactory::Recall),
Box::new(memory::MemoryToolFactory::Remember),
]
diff --git a/src-tauri/src/commands/asr.rs b/src-tauri/src/commands/asr.rs
index 142e6ea..5333a09 100644
--- a/src-tauri/src/commands/asr.rs
+++ b/src-tauri/src/commands/asr.rs
@@ -99,7 +99,12 @@ pub async fn asr_realtime_start(
state: State<'_, AppState>,
) -> Result {
let session_id = uuid::Uuid::new_v4().to_string();
- let tx = crate::core::asr_realtime::start_session(&state.db, &app, session_id.clone()).await?;
+ let tx = crate::core::asr_realtime::start_session(
+ &state.db,
+ std::sync::Arc::new(app.clone()),
+ session_id.clone(),
+ )
+ .await?;
state.asr_sessions.lock().await.insert(session_id.clone(), tx);
Ok(session_id)
}
diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs
index d49a8f6..41fd949 100644
--- a/src-tauri/src/commands/blueprint.rs
+++ b/src-tauri/src/commands/blueprint.rs
@@ -180,6 +180,207 @@ async fn insert_message(
Ok(())
}
+/// P3 蓝图评审结果(孵化台深化 §3.3 默认多轮评估):四维打分 + 待补强项 + 总评。
+/// critic(`spec_grader` 角色)落稿后打分;低于阈值且有轮次预算则回喂起草 Agent 自动修订。
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
+pub struct BlueprintEval {
+ #[serde(default)]
+ pub prd_completeness: i64,
+ #[serde(default)]
+ pub spec_executability: i64,
+ #[serde(default)]
+ pub task_granularity: i64,
+ #[serde(default)]
+ pub code_fit: i64,
+ #[serde(default)]
+ pub gaps: Vec,
+ #[serde(default)]
+ pub summary: String,
+}
+
+impl BlueprintEval {
+ /// 四维最低分(短板决定是否需再修订)。
+ pub fn min_score(&self) -> i64 {
+ self.prd_completeness
+ .min(self.spec_executability)
+ .min(self.task_granularity)
+ .min(self.code_fit)
+ }
+ /// 是否达标(所有维度 ≥ 阈值)。
+ pub fn passes(&self, threshold: i64) -> bool {
+ self.min_score() >= threshold
+ }
+}
+
+/// 从 critic 原始输出解析评估 JSON(容忍前后解释/围栏,取首 `{` 到末 `}`)。
+pub(crate) fn parse_eval(raw: &str) -> Option {
+ let start = raw.find('{')?;
+ let end = raw.rfind('}')?;
+ serde_json::from_str(raw.get(start..=end)?).ok()
+}
+
+/// 落库一次评估:写 `eval_json` + 记一条 `role='eval'` 消息(总评)。可测。
+pub(crate) async fn store_eval(
+ db: &crate::db::Db,
+ draft_id: &str,
+ eval: &BlueprintEval,
+) -> Result<(), String> {
+ let json = serde_json::to_string(eval).map_err(|e| e.to_string())?;
+ sqlx::query("UPDATE blueprint_drafts SET eval_json=?, updated_at=? WHERE id=?")
+ .bind(&json)
+ .bind(now_str())
+ .bind(draft_id)
+ .execute(db)
+ .await
+ .map_err(|e| e.to_string())?;
+ let note = if eval.summary.trim().is_empty() {
+ format!("评估:最低分 {}/10", eval.min_score())
+ } else {
+ eval.summary.trim().to_string()
+ };
+ insert_message(db, draft_id, "eval", ¬e, "").await?;
+ Ok(())
+}
+
+/// P3 评估开关(app_settings 键 `blueprint.eval_enabled`,默认关=旧行为零回归)。
+async fn blueprint_eval_enabled(db: &crate::db::Db) -> bool {
+ sqlx::query_as::<_, (String,)>("SELECT value FROM app_settings WHERE key='blueprint.eval_enabled'")
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ .map(|(v,)| v == "true" || v == "1")
+ .unwrap_or(false)
+}
+
+/// 落稿后自动评审(P3 默认多轮评估的评审步):spec_grader 四维打分 → 落 eval_json + eval 消息。
+/// best-effort:LLM 不可用 / 解析失败则跳过,不阻断起草主流程。LLM 行为需运行时验证;
+/// 本函数的解析/落库逻辑由 `parse_eval`/`store_eval` 单测覆盖。
+pub(crate) async fn run_blueprint_critic(
+ db: &crate::db::Db,
+ project_id: &str,
+ draft_id: &str,
+) -> Result<(), String> {
+ let draft = fetch_draft(db, draft_id).await?;
+ let specs_json = serde_json::to_string(&draft.specs).unwrap_or_default();
+ let tasks_json = serde_json::to_string(&draft.tasklist).unwrap_or_default();
+ let prompt = format!(
+ "请评审下面这份大需求蓝图并按四维打分(只输出评估 JSON)。\n\n【PRD】\n{}\n\n【规格】\n{}\n\n【任务清单】\n{}",
+ draft.prd_markdown, specs_json, tasks_json
+ );
+ let raw = crate::agents::llm::run_system_role_text(
+ db,
+ "spec_grader",
+ &prompt,
+ crate::agents::roles::builtin_prompt("spec_grader"),
+ Some(project_id),
+ None,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
+ if let Some(eval) = parse_eval(&raw) {
+ store_eval(db, draft_id, &eval).await?;
+ }
+ Ok(())
+}
+
+/// P1 grounding(孵化台深化 §3.1/§3.4):起草/修正前,从统一上下文基质 assemble 一小段
+/// 与本项目相关的已有上下文(需求 / 编码执行日志 / 审核意见 / 既有草稿),注入 prompt 作为
+/// grounding,让起草 Agent 看到「项目此前发生过什么」而非凭空生成。
+///
+/// 复用编码台取景框(issue/spec/code_agent_log/llm_trace)+ 小预算(~6KB);正文经保尾摘要。
+/// 防御性再过一遍注入闸(源头 intake 已过滤,此处兜底);无基质条目时返回空串(prompt 不变,
+/// 即旧行为,零回归)。基质空/查询失败均静默降级。
+async fn build_substrate_grounding(db: &crate::db::Db, project_id: &str) -> String {
+ use crate::core::{context, lens};
+ let preset = lens::preset_for_role("coding");
+ let req = context::ContextRequest {
+ project_id: project_id.to_string(),
+ include: preset.include,
+ refs: vec![],
+ budget_bytes: 6000,
+ };
+ let items = match context::assemble(db, &req).await {
+ Ok(v) if !v.is_empty() => v,
+ _ => return String::new(),
+ };
+ let mut out = String::new();
+ for it in items.iter().take(6) {
+ let snip = context::fetch_content(db, it, 300).await.unwrap_or_default();
+ let snip = snip.trim();
+ // 源头已过注入闸,此处兜底:疑似注入的条目跳过,不喂进起草 prompt。
+ if snip.is_empty() || crate::core::security::has_obvious_injection(snip) {
+ continue;
+ }
+ out.push_str(&format!("- [{}] {}:{}\n", it.source_kind, it.title, snip));
+ }
+ if out.is_empty() {
+ return String::new();
+ }
+ format!(
+ "\n【项目已有上下文(来自统一基质,供参考理解现状,勿照抄)】\n{out}"
+ )
+}
+
+/// 追问挂起(孵化台深化 §3.2 断点续跑):起草 Agent 调 `ask_user` 终止型工具时,把问题
+/// 落 `blueprint_messages(role='question')`,草稿置 `awaiting_answer` + `pending_question`。
+/// 纯状态转换,供 P2 工具循环收口调用;本身可独立单测。
+pub(crate) async fn set_awaiting_answer(
+ db: &crate::db::Db,
+ draft_id: &str,
+ question: &str,
+) -> Result<(), String> {
+ insert_message(db, draft_id, "question", question, "").await?;
+ sqlx::query(
+ "UPDATE blueprint_drafts SET status='awaiting_answer', pending_question=?, updated_at=? WHERE id=?",
+ )
+ .bind(question)
+ .bind(now_str())
+ .bind(draft_id)
+ .execute(db)
+ .await
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+/// 回答追问的状态转换(可测 helper):记录答复 → 清 `pending_question` → 状态回 `drafting`。
+/// **续跑机制**(§3.2):不在此保存运行时状态;下一轮 `refine_blueprint_draft` 从
+/// `blueprint_messages` 重建 transcript(此刻已含 Q&A)再起工具循环,天然幂等。
+pub(crate) async fn apply_answer(
+ db: &crate::db::Db,
+ draft_id: &str,
+ answer: &str,
+) -> Result<(), String> {
+ insert_message(db, draft_id, "answer", answer, "").await?;
+ sqlx::query(
+ "UPDATE blueprint_drafts SET status='drafting', pending_question='', updated_at=? WHERE id=?",
+ )
+ .bind(now_str())
+ .bind(draft_id)
+ .execute(db)
+ .await
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+/// 命令:回答孵化台起草 Agent 的追问,清挂起态、返回更新后的草稿视图(断点续跑,见 §3.2)。
+#[tauri::command]
+pub async fn answer_blueprint_question(
+ draft_id: String,
+ answer: String,
+ state: State<'_, AppState>,
+) -> Result {
+ let answer = answer.trim().to_string();
+ if answer.is_empty() {
+ return Err("答复不能为空".into());
+ }
+ if crate::core::security::has_obvious_injection(&answer) {
+ return Err("答复文本疑似含注入内容,已拒绝".into());
+ }
+ apply_answer(&state.db, &draft_id, &answer).await?;
+ load_view(&state.db, &draft_id).await
+}
+
/// 把草稿的 specs/tasklist 写回 DB(序列化进 JSON 列)+ 刷新 updated_at。
async fn persist_draft_body(
db: &crate::db::Db,
@@ -203,6 +404,37 @@ async fn persist_draft_body(
.execute(db)
.await
.map_err(|e| e.to_string())?;
+
+ // 上下文基质登记(基质 §3.2:孵化台草稿此前与会议室/CR 上下文不互通,是关键缺口)。
+ // 落稿即把该大需求草稿投影为 ContextItem,让编码/审核/会议室等环节可取用其 PRD。
+ // best-effort:查 draft 归属项目/标题后登记;content_ref=bp: 对应 fetch_content 的 bp 读取器。
+ if let Some((project_id, title)) =
+ sqlx::query_as::<_, (String, String)>("SELECT project_id, title FROM blueprint_drafts WHERE id=?")
+ .bind(draft_id)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ {
+ let cref = format!("bp:{draft_id}");
+ let disp = if title.trim().is_empty() { "孵化台草稿" } else { title.trim() };
+ let _ = crate::core::context::register(
+ db,
+ crate::core::context::NewContextItem {
+ project_id: &project_id,
+ source_kind: crate::core::context::source_kind::INCUBATOR_DRAFT,
+ source_id: draft_id,
+ title: disp,
+ origin_stage: "requirement",
+ origin_actor: "spec_writer",
+ content_ref: &cref,
+ size_hint: prd_markdown.len() as i64,
+ trust: crate::core::context::trust::TRUSTED,
+ labels: "[]",
+ },
+ )
+ .await;
+ }
Ok(())
}
@@ -465,6 +697,9 @@ pub async fn refine_blueprint_draft(
hist_block.push_str("(无)\n");
}
+ // P1 grounding:注入项目已有基质上下文(无则空串,prompt 不变=旧行为)。
+ let grounding = build_substrate_grounding(&state.db, &draft.project_id).await;
+
let prompt = format!(
r#"你正在与用户多轮打磨一份项目蓝图。下面是【当前蓝图】(JSON,规格与任务都带稳定 id)、【对话历史】与用户【本轮指令】。
请在当前蓝图基础上做**最小必要改动**满足指令,然后回传**整份更新后的蓝图**。
@@ -480,7 +715,7 @@ pub async fn refine_blueprint_draft(
【对话历史】
{history}
-
+{grounding}
【本轮指令】
{instruction}
@@ -493,6 +728,7 @@ pub async fn refine_blueprint_draft(
}}"#,
current = current_json,
history = hist_block.trim(),
+ grounding = grounding,
instruction = instruction,
);
@@ -532,6 +768,11 @@ pub async fn refine_blueprint_draft(
insert_message(&state.db, &draft_id, "user", &instruction, "").await?;
insert_message(&state.db, &draft_id, "assistant", &change_summary, &change_summary).await?;
+ // P3:若开启评估,落稿后自动跑 critic 打分(best-effort,不阻断;开关默认关=零回归)。
+ if blueprint_eval_enabled(&state.db).await {
+ let _ = run_blueprint_critic(&state.db, &draft.project_id, &draft_id).await;
+ }
+
load_view(&state.db, &draft_id).await
}
@@ -851,6 +1092,141 @@ mod tests {
assert!(parse_raw("没有任何大括号").is_err());
}
+ /// P3 评估解析 + 阈值:容忍围栏;min_score 取四维最低;passes 全维达标才 true。
+ #[test]
+ fn eval_parse_and_threshold() {
+ let raw = "评估如下:\n```json\n{\"prd_completeness\":8,\"spec_executability\":5,\"task_granularity\":9,\"code_fit\":7,\"gaps\":[\"验收标准缺量化\"],\"summary\":\"整体可用,规格偏空\"}\n```";
+ let e = parse_eval(raw).expect("parse eval");
+ assert_eq!(e.min_score(), 5, "四维最低=规格可执行性 5");
+ assert!(!e.passes(7), "阈值 7 → 短板 5 不达标");
+ assert!(e.passes(5), "阈值 5 → 达标");
+ assert_eq!(e.gaps.len(), 1);
+ }
+
+ /// P3 落库:store_eval 写 eval_json + 记 role='eval' 消息。
+ #[tokio::test]
+ async fn store_eval_persists_json_and_message() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE blueprint_drafts (id TEXT PRIMARY KEY, eval_json TEXT NOT NULL DEFAULT '', updated_at TEXT)")
+ .execute(&db).await.unwrap();
+ sqlx::query("CREATE TABLE blueprint_messages (id TEXT PRIMARY KEY, draft_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', change_summary TEXT NOT NULL DEFAULT '', created_at TEXT)")
+ .execute(&db).await.unwrap();
+ sqlx::query("INSERT INTO blueprint_drafts (id) VALUES ('d1')").execute(&db).await.unwrap();
+
+ let eval = BlueprintEval { prd_completeness: 8, spec_executability: 6, task_granularity: 7, code_fit: 7, gaps: vec![], summary: "还行".into() };
+ store_eval(&db, "d1", &eval).await.unwrap();
+ let (json,): (String,) = sqlx::query_as("SELECT eval_json FROM blueprint_drafts WHERE id='d1'").fetch_one(&db).await.unwrap();
+ assert!(json.contains("\"spec_executability\":6"));
+ let (role,): (String,) = sqlx::query_as("SELECT role FROM blueprint_messages WHERE draft_id='d1'").fetch_one(&db).await.unwrap();
+ assert_eq!(role, "eval");
+ }
+
+ /// P1 grounding:从基质 assemble 出的项目上下文被注入起草 prompt;空项目返回空串(旧行为)。
+ #[tokio::test]
+ async fn substrate_grounding_injects_project_context() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE context_index (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
+ origin_stage TEXT NOT NULL DEFAULT '', origin_actor TEXT NOT NULL DEFAULT '',
+ content_ref TEXT NOT NULL DEFAULT '', size_hint INTEGER NOT NULL DEFAULT 0,
+ trust TEXT NOT NULL DEFAULT 'trusted', labels TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')))",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ crate::core::context::register(
+ &db,
+ crate::core::context::NewContextItem::trusted(
+ "p1",
+ crate::core::context::source_kind::CODE_AGENT_LOG,
+ "l1",
+ "上次编码修了登录 bug",
+ "",
+ ),
+ )
+ .await
+ .unwrap();
+
+ let g = build_substrate_grounding(&db, "p1").await;
+ assert!(g.contains("上次编码修了登录 bug"), "基质上下文注入 grounding");
+ assert!(g.contains("项目已有上下文"));
+
+ let empty = build_substrate_grounding(&db, "none").await;
+ assert!(empty.is_empty(), "空项目 → 空 grounding(prompt 不变=旧行为)");
+ }
+
+ /// 追问状态机(P2 断点续跑):ask_user 挂起 → awaiting_answer + pending_question;
+ /// 回答 → 清挂起、回 drafting;Q&A 均进 transcript 供下轮 refine 重建续跑。
+ #[tokio::test]
+ async fn awaiting_answer_state_machine_roundtrip() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE blueprint_drafts (id TEXT PRIMARY KEY, project_id TEXT,
+ status TEXT NOT NULL DEFAULT 'drafting', pending_question TEXT NOT NULL DEFAULT '',
+ updated_at TEXT)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE blueprint_messages (id TEXT PRIMARY KEY, draft_id TEXT NOT NULL,
+ role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '',
+ change_summary TEXT NOT NULL DEFAULT '', created_at TEXT)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO blueprint_drafts (id, project_id, status) VALUES ('d1','p1','drafting')")
+ .execute(&db)
+ .await
+ .unwrap();
+
+ set_awaiting_answer(&db, "d1", "需要支持第三方登录吗?").await.unwrap();
+ let (status, pending): (String, String) =
+ sqlx::query_as("SELECT status, pending_question FROM blueprint_drafts WHERE id='d1'")
+ .fetch_one(&db)
+ .await
+ .unwrap();
+ assert_eq!(status, "awaiting_answer");
+ assert_eq!(pending, "需要支持第三方登录吗?");
+
+ apply_answer(&db, "d1", "是,支持微信登录").await.unwrap();
+ let (status2, pending2): (String, String) =
+ sqlx::query_as("SELECT status, pending_question FROM blueprint_drafts WHERE id='d1'")
+ .fetch_one(&db)
+ .await
+ .unwrap();
+ assert_eq!(status2, "drafting", "回答后回到起草态");
+ assert_eq!(pending2, "", "pending_question 已清");
+
+ let roles: Vec =
+ sqlx::query_as::<_, (String,)>("SELECT role FROM blueprint_messages WHERE draft_id='d1'")
+ .fetch_all(&db)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|(r,)| r)
+ .collect();
+ assert!(roles.contains(&"question".to_string()));
+ assert!(roles.contains(&"answer".to_string()));
+ }
+
#[test]
fn spec_and_task_deserialize_with_missing_id_and_defaults() {
// 起草轮模型不带 id,且 task 可缺 category/severity —— 必须回落默认且解析不报错。
diff --git a/src-tauri/src/commands/change_requests.rs b/src-tauri/src/commands/change_requests.rs
index 4878203..ddd35b0 100644
--- a/src-tauri/src/commands/change_requests.rs
+++ b/src-tauri/src/commands/change_requests.rs
@@ -430,7 +430,8 @@ pub async fn ai_resolve_merge_conflict(
let db = state.db.clone();
let tx = state.job_tx.clone();
tokio::spawn(async move {
- if let Err(e) = crate::tasks::merge::ai_resolve_conflict(&db, &tx, &app, &cr_id).await {
+ let sink: std::sync::Arc = std::sync::Arc::new(app);
+ if let Err(e) = crate::tasks::merge::ai_resolve_conflict(&db, &tx, &sink, &cr_id).await {
tracing::info!("ai_resolve_merge_conflict failed for {}: {}", cr_id, e);
}
});
@@ -1674,6 +1675,30 @@ async fn record_admin_decision(
.await
.map_err(|e| e.to_string())?;
+ // 上下文基质登记(基质 §2.2:CR/审核意见此前各自页面、编码时看不到「人在审核里说过什么」)。
+ // 幂等按 CR 归并:同一 CR 的多次审核决策刷新同一条 cr_review(crv: 读取器拼接其全部建议)。
+ // 仅当决策关联到具体 CR 时登记(review_1 早于 CR 存在时 change_request_id 为 None)。
+ if let Some(cr_id) = change_request_id {
+ let cref = format!("crv:{cr_id}");
+ let title = format!("审核意见 · {stage}");
+ let _ = crate::core::context::register(
+ db,
+ crate::core::context::NewContextItem {
+ project_id,
+ source_kind: crate::core::context::source_kind::CR_REVIEW,
+ source_id: cr_id,
+ title: &title,
+ origin_stage: "review",
+ origin_actor: admin_id,
+ content_ref: &cref,
+ size_hint: suggestions.map(|s| s.len() as i64).unwrap_or(0),
+ trust: crate::core::context::trust::TRUSTED,
+ labels: "[]",
+ },
+ )
+ .await;
+ }
+
// Innate: capture the human gate decision as project knowledge (fire-and-forget).
let pid = project_id.to_string();
let content = format!(
diff --git a/src-tauri/src/commands/conflicts.rs b/src-tauri/src/commands/conflicts.rs
index 76cfc88..33e40b0 100644
--- a/src-tauri/src/commands/conflicts.rs
+++ b/src-tauri/src/commands/conflicts.rs
@@ -277,8 +277,10 @@ pub async fn resolve_conflict_manually(
"AutoForge: 人工解决合并冲突({} → {})",
dev_ref, session.branch_name
);
+ let sink: std::sync::Arc =
+ std::sync::Arc::new(app.clone());
if let Err(e) =
- crate::tasks::merge::finalize_resolution(&db, &tx, &app, &session, &cr, &issue, &commit_msg)
+ crate::tasks::merge::finalize_resolution(&db, &tx, &sink, &session, &cr, &issue, &commit_msg)
.await
{
tracing::warn!("resolve_conflict_manually finalize failed for {}: {}", cr_id, e);
diff --git a/src-tauri/src/commands/context.rs b/src-tauri/src/commands/context.rs
new file mode 100644
index 0000000..0c897ae
--- /dev/null
+++ b/src-tauri/src/commands/context.rs
@@ -0,0 +1,181 @@
+//! 上下文基质的只读 IPC 出口(薄包装 `core::context`)。
+//!
+//! 供前端「取景框(Lens)」界面枚举/装配/懒取上下文条目——即基质设计 §4.1 的三种访问。
+//! 命令保持薄包装:只做「取 state → 调纯 Rust core → 返回」,业务逻辑全在 `core::context`。
+
+use crate::core::context::{self, ContextRequest};
+use crate::models::context_item::ContextItem;
+use crate::state::AppState;
+use tauri::State;
+
+/// 统一 dispatch 出口(DUAL_HEAD M2 机制的 Tauri 对接点):前端经此调用注册表里走统一
+/// 契约的命令。**对接层不枚举命令名**(DUAL_HEAD 红线)——此函数只做「建 Ctx → 查全局
+/// 注册表 → 分发」,加/删注册表命令时本函数 diff 为 0。当前注册表含基质只读命令
+/// `ctx.list` / `ctx.assemble` / `ctx.fetch`;M2 逐域迁移时更多命令自动经此可达。
+#[tauri::command]
+pub async fn rpc_dispatch(
+ cmd: String,
+ args: serde_json::Value,
+ state: State<'_, AppState>,
+ app: tauri::AppHandle,
+) -> Result {
+ let ctx = crate::core::rpc::Ctx {
+ state: std::sync::Arc::new(state.inner().clone()),
+ sink: std::sync::Arc::new(app),
+ principal: crate::core::rpc::Principal::local_owner(),
+ };
+ crate::core::rpc::global_registry()
+ .dispatch(&cmd, ctx, args)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// 枚举一个项目的上下文条目(薄索引元数据;access #1)。
+/// `kinds` 为空 = 不限来源;`limit` 默认 200;`query` 有值 = 标题关键词搜索
+/// (下推 provider 全量召回,结果按 kind 优先级 + 时间排序)。
+#[tauri::command]
+pub async fn list_context_items(
+ project_id: String,
+ kinds: Option>,
+ limit: Option,
+ query: Option,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let kinds_v = kinds.unwrap_or_default();
+ let kinds_ref: Vec<&str> = kinds_v.iter().map(|s| s.as_str()).collect();
+ context::list(
+ &state.db,
+ &project_id,
+ &kinds_ref,
+ limit.unwrap_or(200),
+ query.as_deref(),
+ )
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// 按取景框装配上下文条目(refs 置顶 → kind 优先级 → 预算裁剪;access #1 + §4.2)。
+#[tauri::command]
+pub async fn assemble_context(
+ project_id: String,
+ include: Option>,
+ refs: Option>,
+ budget_bytes: Option,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let req = ContextRequest {
+ project_id,
+ include: include.unwrap_or_default(),
+ refs: refs.unwrap_or_default(),
+ // None = 默认预算兜底;Some(0) = 显式不限(逃生口)。
+ budget_bytes: budget_bytes.unwrap_or(context::DEFAULT_BUDGET_BYTES),
+ };
+ context::assemble(&state.db, &req)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// 「引用到会议室」的落库逻辑(context_ref 块的产出方,G4 活体化,可测):
+/// 在目标会议室插入一条含 context_ref 块的消息(from_agent=NULL 表操作者引用)。
+pub(crate) async fn insert_context_ref_message(
+ db: &crate::db::Db,
+ conversation_id: &str,
+ item: &ContextItem,
+) -> Result {
+ let content_json =
+ serde_json::to_string(&vec![context::context_ref_block(item)]).map_err(|e| e.to_string())?;
+ let msg_id = uuid::Uuid::new_v4().to_string();
+ sqlx::query("INSERT INTO messages (id, conversation_id, from_agent, content_json) VALUES (?, ?, NULL, ?)")
+ .bind(&msg_id)
+ .bind(conversation_id)
+ .bind(&content_json)
+ .execute(db)
+ .await
+ .map_err(|e| e.to_string())?;
+ Ok(msg_id)
+}
+
+/// 从取景框把一条基质条目引用到指定会议室(用户 pin:让基质条目进入协作对话,可展开取正文)。
+#[tauri::command]
+pub async fn cite_context_to_conversation(
+ conversation_id: String,
+ context_item_id: String,
+ state: State<'_, AppState>,
+ app: tauri::AppHandle,
+) -> Result<(), String> {
+ let item = context::get(&state.db, &context_item_id)
+ .await
+ .map_err(|e| e.to_string())?
+ .ok_or("上下文条目不存在")?;
+ let msg_id = insert_context_ref_message(&state.db, &conversation_id, &item).await?;
+ crate::core::event::emit(
+ &app,
+ crate::core::event::AppEvent::MessageReceived {
+ conversation_id,
+ message_id: msg_id,
+ },
+ );
+ Ok(())
+}
+
+/// 懒取一条上下文条目正文(大体量来源走 G3 尾部摘要;access #2)。`max_chars` 默认 8192。
+#[tauri::command]
+pub async fn fetch_context_content(
+ id: String,
+ max_chars: Option,
+ state: State<'_, AppState>,
+) -> Result {
+ let item = context::get(&state.db, &id)
+ .await
+ .map_err(|e| e.to_string())?
+ .ok_or_else(|| format!("context item {} not found", id))?;
+ context::fetch_content(&state.db, &item, max_chars.unwrap_or(8192) as usize)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::context::{register, source_kind, NewContextItem};
+
+ /// context_ref 产出方:cite 把基质条目落成一条含 context_ref 块的会议室消息。
+ #[tokio::test]
+ async fn insert_context_ref_message_writes_block() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE context_index (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
+ origin_stage TEXT NOT NULL DEFAULT '', origin_actor TEXT NOT NULL DEFAULT '',
+ content_ref TEXT NOT NULL DEFAULT '', size_hint INTEGER NOT NULL DEFAULT 0,
+ trust TEXT NOT NULL DEFAULT 'trusted', labels TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT, updated_at TEXT)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE messages (id TEXT PRIMARY KEY, conversation_id TEXT, from_agent TEXT, content_json TEXT)")
+ .execute(&db)
+ .await
+ .unwrap();
+ register(&db, NewContextItem::trusted("p1", source_kind::ISSUE, "i1", "登录页需求", "issue:i1"))
+ .await
+ .unwrap();
+ let item = context::get(&db, "issue:i1").await.unwrap().unwrap();
+
+ let mid = insert_context_ref_message(&db, "conv1", &item).await.unwrap();
+ assert!(!mid.is_empty());
+ let (cj,): (String,) = sqlx::query_as("SELECT content_json FROM messages WHERE conversation_id='conv1'")
+ .fetch_one(&db)
+ .await
+ .unwrap();
+ assert!(cj.contains("\"t\":\"context_ref\""));
+ assert!(cj.contains("issue:i1"));
+ assert!(cj.contains("登录页需求"));
+ }
+}
diff --git a/src-tauri/src/commands/conversation_archives.rs b/src-tauri/src/commands/conversation_archives.rs
index 2108749..45bdcbd 100644
--- a/src-tauri/src/commands/conversation_archives.rs
+++ b/src-tauri/src/commands/conversation_archives.rs
@@ -2,7 +2,7 @@ use crate::models::conversation_archive::{
ArchiveSearchHit, ArchivedMessage, ConversationArchiveDetail, ConversationArchiveSummary,
};
use crate::state::AppState;
-use tauri::State;
+use tauri::{AppHandle, State};
use uuid::Uuid;
const INNATE_SENDER: &str = "__innate__";
@@ -89,10 +89,73 @@ fn count_and_snippet(text: &str, query: &str) -> (i64, String) {
(count, snippet)
}
+/// 把归档消息拼成「发言人:正文」逐字记录,供 Innate 蒸馏学习。跳过 Innate 系统消息、
+/// 斜杠命令与空文本;上限 16K 字符(蒸馏侧还会再截,这里先粗筛)。
+fn build_transcript(messages: &[ArchivedMessage]) -> String {
+ let mut lines: Vec = Vec::new();
+ for m in messages {
+ if m.is_innate {
+ continue;
+ }
+ let text = extract_plain_text(&m.content_json);
+ let text = text.trim();
+ if text.is_empty() || text.starts_with('/') {
+ continue;
+ }
+ lines.push(format!("{}:{}", m.author, text));
+ }
+ let joined = lines.join("\n");
+ joined.chars().take(16_000).collect()
+}
+
+/// 归档后台学习:把整段会议蒸馏成可复用经验存入 Innate,并在(已清空的)房间回一条
+/// 回执消息。best-effort——蒸馏不出东西或蒸馏器未配置时静默跳过,绝不影响归档结果。
+async fn learn_from_archive(
+ app: AppHandle,
+ db: crate::db::Db,
+ conversation_id: String,
+ title: String,
+ project_id: Option,
+ project_name: Option,
+ transcript: String,
+) {
+ let insights =
+ crate::knowledge::distill_meeting(&db, &title, project_name.as_deref(), &transcript).await;
+ if insights.is_empty() {
+ return;
+ }
+ let scope_label = if project_id.is_some() { "本项目知识库" } else { "通用知识库(跨项目)" };
+ for it in &insights {
+ // scope 感知写入(None → 共享库),复用 /remember 的路径:会做长度上限 + 净化 +
+ // 触发自动进化计数。
+ let _ = crate::knowledge::cmd_remember(project_id.as_deref(), &it.content, &it.trigger).await;
+ }
+ // 立即蒸馏 + 整理(与合并后学习一致,不等攒够阈值)。
+ match project_id.as_deref() {
+ Some(pid) => crate::knowledge::kb_evolve(pid).await,
+ None => crate::knowledge::kb_evolve_shared().await,
+ }
+ // 回执:让操作者看到这次归档到底学到了什么。
+ let bullets = insights
+ .iter()
+ .map(|it| format!("- {}", it.content.replace('\n', " ")))
+ .collect::>()
+ .join("\n");
+ let body = format!(
+ "🧠 **已从归档会议《{}》学习**\n\n沉淀 {} 条经验到{}:\n\n{}",
+ title,
+ insights.len(),
+ scope_label,
+ bullets
+ );
+ let _ = crate::commands::knowledge::post_innate_message(&app, &db, &conversation_id, &body).await;
+}
+
/// 归档会议室:把当前消息打包成不可变只读快照,随后清空会议室(房间保留可继续使用)。
#[tauri::command]
pub async fn archive_conversation(
conversation_id: String,
+ app: AppHandle,
state: State<'_, AppState>,
) -> Result {
let conv: Option<(String, Option, Option)> =
@@ -216,6 +279,29 @@ pub async fn archive_conversation(
.await
.map_err(|e| e.to_string())?;
+ // 归档学习(可开关,默认开):后台把整段会议蒸馏进 Innate。fire-and-forget,
+ // 不阻塞归档返回,失败也不影响归档结果。
+ if crate::commands::knowledge::load_knowledge_settings(&state.db)
+ .await
+ .archive_learning
+ {
+ let transcript = build_transcript(&messages);
+ if !transcript.trim().is_empty() {
+ let app = app.clone();
+ let db = state.db.clone();
+ let conv_id = conversation_id.clone();
+ let title2 = title.clone();
+ let project_id2 = project_id.clone();
+ let project_name2 = project_name.clone();
+ tokio::spawn(async move {
+ learn_from_archive(
+ app, db, conv_id, title2, project_id2, project_name2, transcript,
+ )
+ .await;
+ });
+ }
+ }
+
Ok(ConversationArchiveSummary {
id: archive_id,
conversation_id,
diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs
index 37565e9..cf07179 100644
--- a/src-tauri/src/commands/conversations.rs
+++ b/src-tauri/src/commands/conversations.rs
@@ -55,6 +55,7 @@ pub async fn list_conversations(
SELECT conversation_id, content_json, created_at,
ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC) AS rn
FROM messages
+ WHERE deleted_at IS NULL
)
WHERE rn = 1",
)
@@ -73,6 +74,7 @@ pub async fn list_conversations(
FROM messages m
LEFT JOIN conversation_reads r ON r.conversation_id = m.conversation_id
WHERE m.from_agent IS NOT NULL
+ AND m.deleted_at IS NULL
AND m.created_at > COALESCE(r.read_at, '1970-01-01')
GROUP BY m.conversation_id",
)
@@ -184,7 +186,7 @@ pub async fn list_messages(
FROM (
SELECT *
FROM messages
- WHERE conversation_id=?
+ WHERE conversation_id=? AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 300
)
@@ -577,6 +579,38 @@ pub async fn remove_conversation_member(
conversation_detail(&state.db, conv).await
}
+/// 软删除单条消息:只把 messages.deleted_at 打上时间戳,从会议室气泡列表隐藏,
+/// 消息本体仍保留在库中(可后续恢复/审计)。幂等(已删除的再删无副作用)。
+#[tauri::command]
+pub async fn soft_delete_message(
+ message_id: String,
+ state: State<'_, AppState>,
+) -> Result<(), String> {
+ let affected = sqlx::query(
+ "UPDATE messages SET deleted_at = datetime('now')
+ WHERE id = ? AND deleted_at IS NULL",
+ )
+ .bind(&message_id)
+ .execute(&state.db)
+ .await
+ .map_err(|e| e.to_string())?
+ .rows_affected();
+
+ if affected == 0 {
+ // 已删除视为成功(幂等);仅当消息根本不存在时报错。
+ let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM messages WHERE id=?")
+ .bind(&message_id)
+ .fetch_optional(&state.db)
+ .await
+ .map_err(|e| e.to_string())?;
+ if exists.is_none() {
+ return Err(format!("message {} not found", message_id));
+ }
+ }
+
+ Ok(())
+}
+
#[tauri::command]
pub async fn delete_group_conversation(
conversation_id: String,
@@ -879,6 +913,7 @@ async fn unread_count(db: &crate::db::Db, conversation_id: &str) -> Result COALESCE(
(SELECT read_at FROM conversation_reads WHERE conversation_id=?),
'1970-01-01'
diff --git a/src-tauri/src/commands/knowledge.rs b/src-tauri/src/commands/knowledge.rs
index bbe0691..ef37a16 100644
--- a/src-tauri/src/commands/knowledge.rs
+++ b/src-tauri/src/commands/knowledge.rs
@@ -26,11 +26,19 @@ pub struct KnowledgeSettings {
/// Captures per project that trigger an automatic background evolve. `0`
/// disables the event trigger (timer backstop still applies).
pub capture_threshold: u32,
+ /// When a meeting room is archived, distil the whole conversation into
+ /// reusable insights and feed them to Innate. Default on.
+ #[serde(default = "default_true")]
+ pub archive_learning: bool,
+}
+
+fn default_true() -> bool {
+ true
}
impl Default for KnowledgeSettings {
fn default() -> Self {
- Self { evolve_interval_hours: 12, capture_threshold: 8 }
+ Self { evolve_interval_hours: 12, capture_threshold: 8, archive_learning: true }
}
}
@@ -39,7 +47,8 @@ pub async fn load_knowledge_settings(db: &crate::db::Db) -> KnowledgeSettings {
let mut s = KnowledgeSettings::default();
if let Ok(rows) = sqlx::query_as::<_, (String, String)>(
"SELECT key, value FROM app_settings
- WHERE key IN ('knowledge.evolve_interval_hours', 'knowledge.capture_threshold')",
+ WHERE key IN ('knowledge.evolve_interval_hours', 'knowledge.capture_threshold',
+ 'knowledge.archive_learning')",
)
.fetch_all(db)
.await
@@ -52,6 +61,9 @@ pub async fn load_knowledge_settings(db: &crate::db::Db) -> KnowledgeSettings {
"knowledge.capture_threshold" => {
s.capture_threshold = value.parse().unwrap_or(s.capture_threshold);
}
+ "knowledge.archive_learning" => {
+ s.archive_learning = value != "0" && !value.eq_ignore_ascii_case("false");
+ }
_ => {}
}
}
@@ -76,6 +88,7 @@ pub async fn set_knowledge_settings(
for (key, value) in [
("knowledge.evolve_interval_hours", interval.to_string()),
("knowledge.capture_threshold", threshold.to_string()),
+ ("knowledge.archive_learning", if payload.archive_learning { "1" } else { "0" }.to_string()),
] {
sqlx::query(
"INSERT INTO app_settings (key, value, updated_at)
@@ -90,7 +103,11 @@ pub async fn set_knowledge_settings(
}
// Apply the new threshold to the running process immediately.
crate::knowledge::set_evolve_threshold(threshold);
- Ok(KnowledgeSettings { evolve_interval_hours: interval, capture_threshold: threshold })
+ Ok(KnowledgeSettings {
+ evolve_interval_hours: interval,
+ capture_threshold: threshold,
+ archive_learning: payload.archive_learning,
+ })
}
// ── Embedding 模型配置 ──────────────────────────────────────────────────────
@@ -268,7 +285,7 @@ pub async fn run_conversation_command(
}
/// Insert a system message authored by Innate and emit the receive event.
-async fn post_innate_message(
+pub(crate) async fn post_innate_message(
app: &AppHandle,
db: &crate::db::Db,
conversation_id: &str,
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 1ef31ae..6528d76 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -10,6 +10,7 @@ pub mod review_assist;
pub mod code_agents;
pub mod code_agent_skills;
pub mod conflicts;
+pub mod context;
pub mod intake;
pub mod conversations;
pub mod conversation_archives;
diff --git a/src-tauri/src/commands/orchestration.rs b/src-tauri/src/commands/orchestration.rs
index f242c78..1b08db1 100644
--- a/src-tauri/src/commands/orchestration.rs
+++ b/src-tauri/src/commands/orchestration.rs
@@ -1928,6 +1928,39 @@ async fn finalize_chat_reply(
.await
.map_err(|e| e.to_string())?;
+ // 上下文基质登记(基质 §2.2:Agent 历史输出此前仅同任务内累积、跨任务/跨会话不可取)。
+ // 仅**项目绑定**会议室的成功发言投影为 ContextItem(agent_output 源,atr: 读 output_text)。
+ if ok {
+ if let Some((Some(project_id),)) = sqlx::query_as::<_, (Option,)>(
+ "SELECT project_id FROM conversations WHERE id=?",
+ )
+ .bind(ctx.conversation_id)
+ .fetch_optional(ctx.db)
+ .await
+ .ok()
+ .flatten()
+ {
+ let cref = format!("atr:{run_id}");
+ let title = format!("会议室发言 · {agent_id}");
+ let _ = crate::core::context::register(
+ ctx.db,
+ crate::core::context::NewContextItem {
+ project_id: &project_id,
+ source_kind: crate::core::context::source_kind::AGENT_OUTPUT,
+ source_id: run_id,
+ title: &title,
+ origin_stage: "chat",
+ origin_actor: agent_id,
+ content_ref: &cref,
+ size_hint: text.len() as i64,
+ trust: crate::core::context::trust::TRUSTED,
+ labels: "[]",
+ },
+ )
+ .await;
+ }
+ }
+
event::emit(
ctx.app,
event::AppEvent::MessageReceived {
@@ -2471,6 +2504,44 @@ async fn message_to_prompt_text(db: &crate::db::Db, msg: &Message) -> Result {
+ let ref_id = block.get("ref").and_then(|v| v.as_str()).unwrap_or("");
+ let title = block.get("title").and_then(|v| v.as_str()).unwrap_or("上下文");
+ if !ref_id.is_empty() {
+ match crate::core::context::get(db, ref_id).await {
+ Ok(Some(mut item)) => {
+ // 从稳定 id 反构的条目 project_id 为空;文件类来源(.autoforge 文档/规格…)
+ // 懒取正文需 repo_path,故从所在会话补齐项目(DB 类来源按 source_id 取,不受影响)。
+ if item.project_id.is_empty() {
+ if let Ok(Some((Some(pid),))) = sqlx::query_as::<_, (Option,)>(
+ "SELECT project_id FROM conversations WHERE id=?",
+ )
+ .bind(&msg.conversation_id)
+ .fetch_optional(db)
+ .await
+ {
+ item.project_id = pid;
+ }
+ }
+ let body = crate::core::context::fetch_content(db, &item, 4000)
+ .await
+ .unwrap_or_default();
+ if body.trim().is_empty() {
+ parts.push(format!("[引用上下文: {}]", title));
+ } else {
+ parts.push(format!(
+ "[引用上下文 - {} ({})]\n```\n{}\n```",
+ title, item.source_kind, body
+ ));
+ }
+ }
+ _ => parts.push(format!("[引用上下文: {}]", title)),
+ }
+ }
+ }
_ => {}
}
}
@@ -3042,3 +3113,138 @@ fn emit_task_update(app: &AppHandle, conversation_id: &str, task_id: &str, statu
);
info!("[orchestration] task {} status={}", task_id, status);
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// D1/D2 消费侧:消息里的 `context_ref` 块被展开成正文注入 prompt(万物可引闭环)。
+ #[tokio::test]
+ async fn context_ref_block_expands_into_prompt() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query(
+ "CREATE TABLE context_index (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
+ origin_stage TEXT NOT NULL DEFAULT '', origin_actor TEXT NOT NULL DEFAULT '',
+ content_ref TEXT NOT NULL DEFAULT '', size_hint INTEGER NOT NULL DEFAULT 0,
+ trust TEXT NOT NULL DEFAULT 'trusted', labels TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT, updated_at TEXT)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ // 一条基质缓存条目(正文经 fetch_content 的标题兜底返回)。
+ crate::core::context::register(
+ &db,
+ crate::core::context::NewContextItem::trusted(
+ "p1",
+ crate::core::context::source_kind::CODE_AGENT_LOG,
+ "l1",
+ "上次修了登录 bug 的关键改动",
+ "",
+ ),
+ )
+ .await
+ .unwrap();
+
+ let msg = Message {
+ id: "m1".into(),
+ conversation_id: "c1".into(),
+ from_agent: None,
+ content_json: serde_json::json!([{
+ "t": "context_ref", "ref": "code_agent_log:l1",
+ "kind": "code_agent_log", "title": "编码日志"
+ }])
+ .to_string(),
+ created_at: String::new(),
+ excluded_from_context: false,
+ parent_message_id: None,
+ };
+ let text = message_to_prompt_text(&db, &msg).await.unwrap();
+ assert!(text.contains("引用上下文"), "context_ref 被展开为引用段");
+ assert!(
+ text.contains("上次修了登录 bug 的关键改动"),
+ "被引用条目的正文注入 prompt"
+ );
+
+ // 未知 ref → 优雅降级为标题占位,不报错。
+ let msg2 = Message {
+ content_json: serde_json::json!([{
+ "t": "context_ref", "ref": "issue:nope", "kind": "issue", "title": "某需求"
+ }])
+ .to_string(),
+ ..msg.clone()
+ };
+ let text2 = message_to_prompt_text(&db, &msg2).await.unwrap();
+ assert!(text2.contains("某需求"), "未命中 ref 降级为标题占位");
+ }
+
+ /// 头牌能力:会议室 @ 引用 `.autoforge` 文件 → 经会话补齐项目 → 文件 provider 读正文注入 prompt。
+ #[tokio::test]
+ async fn context_ref_expands_autoforge_file_via_conversation_project() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ // get() 先查 context_index 缓存(此处 miss → 反构最小条目)。
+ sqlx::query(
+ "CREATE TABLE context_index (id TEXT PRIMARY KEY, project_id TEXT, source_kind TEXT,
+ source_id TEXT, title TEXT, origin_stage TEXT, origin_actor TEXT, content_ref TEXT,
+ size_hint INTEGER, trust TEXT, labels TEXT, created_at TEXT, updated_at TEXT)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE projects (id TEXT PRIMARY KEY, repo_path TEXT)")
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT)")
+ .execute(&db)
+ .await
+ .unwrap();
+
+ // 造一个临时仓库 + .autoforge/docs/note.md。
+ let repo = std::env::temp_dir().join(format!("af_ctx_{}", uuid::Uuid::new_v4()));
+ let docs = repo.join(".autoforge").join("docs");
+ tokio::fs::create_dir_all(&docs).await.unwrap();
+ tokio::fs::write(docs.join("note.md"), "登录页 PRD 关键约束:必须支持空密码校验")
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO projects VALUES ('p1', ?)")
+ .bind(repo.to_string_lossy().to_string())
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO conversations VALUES ('c1','p1')")
+ .execute(&db)
+ .await
+ .unwrap();
+
+ let msg = Message {
+ id: "m1".into(),
+ conversation_id: "c1".into(),
+ from_agent: None,
+ content_json: serde_json::json!([{
+ "t": "context_ref", "ref": "workspace_doc:docs/note.md",
+ "kind": "workspace_doc", "title": "note.md"
+ }])
+ .to_string(),
+ created_at: String::new(),
+ excluded_from_context: false,
+ parent_message_id: None,
+ };
+ let text = message_to_prompt_text(&db, &msg).await.unwrap();
+ assert!(
+ text.contains("必须支持空密码校验"),
+ ".autoforge 文件正文经会话补齐项目后注入 prompt,实得:{text}"
+ );
+ let _ = tokio::fs::remove_dir_all(&repo).await;
+ }
+}
diff --git a/src-tauri/src/commands/prototype.rs b/src-tauri/src/commands/prototype.rs
index c32bfe9..8c4768b 100644
--- a/src-tauri/src/commands/prototype.rs
+++ b/src-tauri/src/commands/prototype.rs
@@ -1,4 +1,3 @@
-use crate::models::issue::Issue;
use crate::models::project::Project;
use crate::models::prototype::PrototypePrompt;
use crate::state::AppState;
@@ -6,11 +5,213 @@ use serde::{Deserialize, Serialize};
use tauri::State;
use uuid::Uuid;
+/// 一个可作为原型设计依据的核心文档源(孵化台深化 §3.5B)。
+/// 前端「关联文档」面板据此让用户勾选,`generate_prototype_prompt(doc_refs)` 按选中项拼上下文。
+#[derive(Debug, Clone, Serialize)]
+pub struct DocSource {
+ /// design_md / blueprint_prd / spec / workspace
+ pub kind: String,
+ /// draft_id / category / rel_path(design_md 为空)
+ pub r#ref: String,
+ pub title: String,
+ pub summary: String,
+ pub est_tokens: i64,
+ pub default_on: bool,
+}
+
+fn est_tokens(text: &str) -> i64 {
+ // 粗估:中英文混排约 3-4 字符/token,取 /3 保守偏高。
+ (text.chars().count() as i64 / 3).max(1)
+}
+
+/// 汇总一个项目当前所有可作为原型设计依据的核心文档源(P4:设计契约 / 需求 PRD / 技术规格)。
+/// 供前端「关联文档」面板勾选;`generate_prototype_prompt` 再按选中的 `doc_refs` 拼上下文。
+#[tauri::command]
+pub async fn list_prototype_doc_sources(
+ project_id: String,
+ draft_id: Option,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let project = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id=?")
+ .bind(&project_id)
+ .fetch_optional(&state.db)
+ .await
+ .map_err(|e| e.to_string())?
+ .ok_or("项目不存在")?;
+ Ok(collect_doc_sources(&state.db, &project_id, &project.repo_path, draft_id.as_deref()).await)
+}
+
+/// 汇总核心文档源的纯逻辑(DB + 文件驱动,命令外可测)。
+pub(crate) async fn collect_doc_sources(
+ db: &crate::db::Db,
+ project_id: &str,
+ repo_path: &str,
+ draft_id: Option<&str>,
+) -> Vec {
+ let mut out: Vec = Vec::new();
+
+ // ① 设计契约:DESIGN.md(必选)。
+ if let Some(design) = read_repo_design(repo_path) {
+ out.push(DocSource {
+ kind: "design_md".into(),
+ r#ref: String::new(),
+ title: "DESIGN.md(设计契约)".into(),
+ summary: "项目 UI 设计系统与 token 契约".into(),
+ est_tokens: est_tokens(&design),
+ default_on: true,
+ });
+ }
+
+ // ② 需求文档:孵化台草稿 PRD(从孵化台跳入时默认选中)。
+ if let Some(did) = draft_id.filter(|s| !s.is_empty()) {
+ if let Some((title, prd)) =
+ sqlx::query_as::<_, (String, String)>("SELECT title, prd_markdown FROM blueprint_drafts WHERE id=?")
+ .bind(did)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ {
+ let disp = if title.trim().is_empty() { "孵化台草稿" } else { title.trim() };
+ out.push(DocSource {
+ kind: "blueprint_prd".into(),
+ r#ref: did.to_string(),
+ title: format!("需求 PRD · {disp}"),
+ summary: "孵化台梳理的大需求 PRD".into(),
+ est_tokens: est_tokens(&prd),
+ default_on: true,
+ });
+ }
+ }
+
+ // ③ 技术规格:project_specs 按分类聚合(architecture/api 默认选中)。
+ let specs: Vec<(String, String)> = sqlx::query_as(
+ "SELECT category, title FROM project_specs WHERE project_id=? ORDER BY category",
+ )
+ .bind(project_id)
+ .fetch_all(db)
+ .await
+ .unwrap_or_default();
+ let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new();
+ for (cat, _title) in &specs {
+ if !seen.insert(cat.clone()) {
+ continue; // 同分类只出一条聚合项
+ }
+ out.push(DocSource {
+ kind: "spec".into(),
+ r#ref: cat.clone(),
+ title: format!("技术规格 · {cat}"),
+ summary: "项目规格约束".into(),
+ est_tokens: 0,
+ default_on: matches!(cat.as_str(), "architecture" | "api"),
+ });
+ }
+
+ out
+}
+
+/// 按选中的文档源引用读取正文,拼成 design_ctx(P4 §3.5C)。
+/// ref 形如 `design_md` / `blueprint_prd:` / `spec:`;单条 ~5K、总量 ~18K 封顶;
+/// 防御性过注入闸(疑似注入的文档跳过,不喂进原型 prompt)。
+pub(crate) async fn read_doc_refs(
+ db: &crate::db::Db,
+ repo_path: &str,
+ project_id: &str,
+ doc_refs: &[String],
+) -> String {
+ const PER: usize = 5000;
+ const TOTAL: usize = 18000;
+ let mut ctx = String::new();
+ let mut used = 0usize;
+ for r in doc_refs {
+ if used >= TOTAL {
+ break;
+ }
+ let (header, body): (String, String) = if r == "design_md" {
+ ("# 设计契约(DESIGN.md)".into(), read_repo_design(repo_path).unwrap_or_default())
+ } else if let Some(id) = r.strip_prefix("blueprint_prd:") {
+ let prd = sqlx::query_as::<_, (String,)>("SELECT prd_markdown FROM blueprint_drafts WHERE id=?")
+ .bind(id)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ .map(|(v,)| v)
+ .unwrap_or_default();
+ ("# 需求文档(PRD)".into(), prd)
+ } else if let Some(cat) = r.strip_prefix("spec:") {
+ let content = sqlx::query_as::<_, (String,)>(
+ "SELECT group_concat(content, char(10)) FROM project_specs WHERE project_id=? AND category=?",
+ )
+ .bind(project_id)
+ .bind(cat)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ .and_then(|(v,)| Some(v))
+ .unwrap_or_default();
+ (format!("# 技术规格·{cat}"), content)
+ } else {
+ continue; // 未知 ref(如 workspace:)暂跳过
+ };
+ let body = body.trim();
+ if body.is_empty() || crate::core::security::has_obvious_injection(body) {
+ continue;
+ }
+ let budget = (TOTAL - used).min(PER);
+ let slice: String = body.chars().take(budget).collect();
+ used += slice.len();
+ if !ctx.is_empty() {
+ ctx.push_str("\n\n");
+ }
+ ctx.push_str(&header);
+ ctx.push('\n');
+ ctx.push_str(&slice);
+ }
+ ctx
+}
+
+/// 读取「改现有页面」选中的仓库页面组件源码,拼成「现有页面基础」块。
+/// 守卫读(read_repo_file,防越界)+ 注入过滤 + 单条/总量封顶。
+async fn read_existing_pages(repo_path: &str, refs: &[String]) -> String {
+ const PER: usize = 6000;
+ const TOTAL: usize = 20000;
+ let mut buf = String::new();
+ let mut used = 0usize;
+ for rel in refs.iter().filter(|r| !r.trim().is_empty()).take(6) {
+ if used >= TOTAL {
+ break;
+ }
+ match crate::commands::project_context::read_repo_file(repo_path, rel.trim()) {
+ Ok(content) if !crate::core::security::has_obvious_injection(&content) => {
+ let budget = (TOTAL - used).min(PER);
+ let slice: String = content.chars().take(budget).collect();
+ used += slice.len();
+ buf.push_str(&format!("\n### 文件:{}\n```\n{}\n```\n", rel.trim(), slice));
+ }
+ _ => { /* 读失败/疑似注入:跳过该文件,不阻断生成 */ }
+ }
+ }
+ buf
+}
+
#[tauri::command]
pub async fn list_prototype_prompts(
project_id: Option,
+ // 给了 draft_id 则只列该大需求的原型(按需求归档;从孵化台跳入时传)。
+ draft_id: Option,
state: State<'_, AppState>,
) -> Result, String> {
+ if let Some(did) = draft_id.as_deref().filter(|s| !s.is_empty()) {
+ return sqlx::query_as::<_, PrototypePrompt>(
+ "SELECT * FROM prototype_prompts WHERE draft_id=? ORDER BY created_at DESC LIMIT 200",
+ )
+ .bind(did)
+ .fetch_all(&state.db)
+ .await
+ .map_err(|e| e.to_string());
+ }
match project_id {
Some(pid) => sqlx::query_as::<_, PrototypePrompt>(
"SELECT * FROM prototype_prompts WHERE project_id=? ORDER BY created_at DESC LIMIT 200",
@@ -73,6 +274,12 @@ pub async fn generate_prototype_prompt(
project_id: String,
issue_id: Option,
tool_target: Option,
+ draft_id: Option,
+ doc_refs: Option>,
+ // 'new'(新页面,默认)/ 'existing'(在现有页面基础上改动)。
+ design_mode: Option,
+ // design_mode='existing' 时选中的现有页面组件仓库相对路径。
+ existing_page_refs: Option>,
state: State<'_, AppState>,
) -> Result {
let project = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id=?")
@@ -82,15 +289,20 @@ pub async fn generate_prototype_prompt(
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("project {} not found", project_id))?;
- let issue: Option = if let Some(iid) = &issue_id {
- sqlx::query_as::<_, Issue>("SELECT * FROM issues WHERE id=?")
- .bind(iid)
- .fetch_optional(&state.db)
- .await
- .map_err(|e| e.to_string())?
- } else {
- None
- };
+ // 硬约束:原型提示词必须对应一个孵化台需求(draft)。空 draft_id 直接拒绝,
+ // 且不落库——杜绝生成脱离孵化台需求的「野」提示词(前端也会禁用生成按钮兜住)。
+ let draft_id = draft_id
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .ok_or("原型提示词必须对应一个需求,请先在「需求孵化台」选择或新建一条需求")?;
+ let (draft_title, draft_brief) = sqlx::query_as::<_, (String, String)>(
+ "SELECT title, brief FROM blueprint_drafts WHERE id=?",
+ )
+ .bind(&draft_id)
+ .fetch_optional(&state.db)
+ .await
+ .map_err(|e| e.to_string())?
+ .ok_or("对应的需求不存在(可能已删除),请重新选择")?;
// Pull the project's spec docs as design context (best-effort).
let specs: String = sqlx::query_as::<_, (String, String)>(
@@ -125,14 +337,40 @@ pub async fn generate_prototype_prompt(
design_ctx.push_str(&specs);
}
+ // P4 §3.5C:若前端「关联文档」面板给了显式 doc_refs,按选中文档拼 design_ctx(更准),
+ // 覆盖上面的默认 DESIGN.md+specs 粗放种子;空则回落旧行为(向后兼容)。
+ let doc_refs = doc_refs.unwrap_or_default();
+ if !doc_refs.is_empty() {
+ let picked = read_doc_refs(&state.db, &project.repo_path, &project_id, &doc_refs).await;
+ if !picked.trim().is_empty() {
+ design_ctx = picked;
+ }
+ }
+
+ // 「改现有页面」模式:读选中的现有页面组件源码,作为改动基础前置进 design_ctx,
+ // 并指令模型在其上做增量改动而非从零重设计(新页面模式则不注入,行为不变)。
+ let mode = design_mode.unwrap_or_default();
+ if mode == "existing" {
+ let refs = existing_page_refs.clone().unwrap_or_default();
+ let pages = read_existing_pages(&project.repo_path, &refs).await;
+ if !pages.trim().is_empty() {
+ design_ctx = format!(
+ "# ⚠️ 本次是对【现有页面】的改动,不是新建页面\n\
+ 必须在下面现有页面的基础上做**增量改动**:保持其整体布局、组件层级、交互流与\
+ 视觉风格一致,只针对下方需求描述涉及的部分做修改/新增,不要从零重新设计整个页面。\n\n\
+ ## 现有页面代码(改动基础,务必延续其结构与风格)\n{pages}\n\n---\n\n{design_ctx}"
+ );
+ }
+ }
+
let target = tool_target.unwrap_or_else(|| "generic".to_string());
- let (feature_title, feature_desc) = match &issue {
- Some(i) => (i.title.clone(), i.description.clone()),
- None => (
- format!("{} 产品界面", project.name),
- project.description.clone(),
- ),
+ // 从(必选的)孵化台草稿派生 feature 标题/描述(真实需求,而非「项目名+产品界面」)。
+ let feature_title = if draft_title.trim().is_empty() {
+ format!("{} 界面", project.name)
+ } else {
+ draft_title
};
+ let feature_desc = draft_brief;
let heuristic = heuristic_prompt(&project.name, &target, &feature_title, &feature_desc, &design_ctx);
let prompt = llm_prompt(&state.db, &project.id, &project.name, &target, &feature_title, &feature_desc, &design_ctx)
@@ -141,8 +379,8 @@ pub async fn generate_prototype_prompt(
let id = Uuid::new_v4().to_string();
sqlx::query(
- "INSERT INTO prototype_prompts (id, project_id, issue_id, tool_target, title, prompt)
- VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO prototype_prompts (id, project_id, issue_id, tool_target, title, prompt, draft_id, design_mode)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&project_id)
@@ -150,6 +388,8 @@ pub async fn generate_prototype_prompt(
.bind(&target)
.bind(&feature_title)
.bind(&prompt)
+ .bind(&draft_id)
+ .bind(&mode)
.execute(&state.db)
.await
.map_err(|e| e.to_string())?;
@@ -700,3 +940,107 @@ pub async fn launch_opendesign(state: State<'_, AppState>) -> Result Result {
Ok(tail(&read_log(), 16000))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// P4 文档源汇总:需求 PRD(有 draft 时默认选)+ 技术规格按分类聚合(architecture/api 默认选)。
+ /// design_md 走文件(测试 repo_path 不存在 → 跳过,不影响 DB 源验证)。
+ #[tokio::test]
+ async fn collect_doc_sources_aggregates_prd_and_specs() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE project_specs (id TEXT PRIMARY KEY, project_id TEXT, category TEXT, title TEXT)")
+ .execute(&db).await.unwrap();
+ sqlx::query("CREATE TABLE blueprint_drafts (id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', prd_markdown TEXT NOT NULL DEFAULT '')")
+ .execute(&db).await.unwrap();
+ sqlx::query("INSERT INTO project_specs VALUES ('s1','p1','architecture','架构'),('s2','p1','architecture','架构2'),('s3','p1','testing','测试')")
+ .execute(&db).await.unwrap();
+ sqlx::query("INSERT INTO blueprint_drafts (id, title, prd_markdown) VALUES ('d1','登录改造','# PRD 内容')")
+ .execute(&db).await.unwrap();
+
+ let out = collect_doc_sources(&db, "p1", "/nonexistent-repo", Some("d1")).await;
+ // 需求 PRD 在(默认选)。
+ let prd = out.iter().find(|d| d.kind == "blueprint_prd").expect("有 PRD 源");
+ assert!(prd.default_on && prd.title.contains("登录改造"));
+ // 规格按分类聚合:architecture 一条(去重)+ testing 一条。
+ let specs: Vec<&DocSource> = out.iter().filter(|d| d.kind == "spec").collect();
+ assert_eq!(specs.len(), 2, "两个分类各一条聚合项");
+ let arch = specs.iter().find(|d| d.r#ref == "architecture").unwrap();
+ assert!(arch.default_on, "architecture 默认选中");
+ let testing = specs.iter().find(|d| d.r#ref == "testing").unwrap();
+ assert!(!testing.default_on, "testing 默认不选");
+ }
+
+ /// P4 §3.5C:read_doc_refs 按选中 ref 读正文拼上下文(PRD + spec 分类聚合),跳未知 ref。
+ #[tokio::test]
+ async fn read_doc_refs_assembles_selected_docs() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ sqlx::query("CREATE TABLE blueprint_drafts (id TEXT PRIMARY KEY, prd_markdown TEXT NOT NULL DEFAULT '')")
+ .execute(&db).await.unwrap();
+ sqlx::query("CREATE TABLE project_specs (id TEXT PRIMARY KEY, project_id TEXT, category TEXT, content TEXT)")
+ .execute(&db).await.unwrap();
+ sqlx::query("INSERT INTO blueprint_drafts VALUES ('d1','# 登录页 PRD 正文')").execute(&db).await.unwrap();
+ sqlx::query("INSERT INTO project_specs VALUES ('s1','p1','api','接口约束A'),('s2','p1','api','接口约束B')")
+ .execute(&db).await.unwrap();
+
+ let refs = vec![
+ "blueprint_prd:d1".to_string(),
+ "spec:api".to_string(),
+ "workspace:未知".to_string(), // 未知 ref → 跳过
+ ];
+ let ctx = read_doc_refs(&db, "/nonexistent", "p1", &refs).await;
+ assert!(ctx.contains("# 需求文档(PRD)") && ctx.contains("登录页 PRD 正文"));
+ assert!(ctx.contains("# 技术规格·api") && ctx.contains("接口约束A") && ctx.contains("接口约束B"));
+ }
+
+ /// 原型按需求归档:draft_id 列 + 按 draft_id 过滤(同项目不同需求各自独立列出)。
+ #[tokio::test]
+ async fn prototypes_filter_by_draft() {
+ let db = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ // 与迁移 0021+0083 同构的最小建表(含 draft_id/design_mode)。
+ sqlx::query(
+ "CREATE TABLE prototype_prompts (id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
+ issue_id TEXT, tool_target TEXT NOT NULL DEFAULT 'generic', title TEXT NOT NULL DEFAULT '',
+ prompt TEXT NOT NULL DEFAULT '', draft_id TEXT NOT NULL DEFAULT '',
+ design_mode TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')))",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query(
+ "INSERT INTO prototype_prompts (id, project_id, draft_id, design_mode, title) VALUES
+ ('a','p1','d1','new','登录页'),
+ ('b','p1','d1','existing','登录页改版'),
+ ('c','p1','d2','new','结算页')",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+
+ // 按需求 d1 过滤 → 2 条;d2 → 1 条。
+ let d1 = sqlx::query_as::<_, PrototypePrompt>("SELECT * FROM prototype_prompts WHERE draft_id=? ORDER BY id")
+ .bind("d1").fetch_all(&db).await.unwrap();
+ assert_eq!(d1.len(), 2, "需求 d1 有两个原型");
+ assert_eq!(d1[1].design_mode, "existing", "design_mode 落库可读");
+ let d2 = sqlx::query_as::<_, PrototypePrompt>("SELECT * FROM prototype_prompts WHERE draft_id=?")
+ .bind("d2").fetch_all(&db).await.unwrap();
+ assert_eq!(d2.len(), 1);
+ // 项目级(不带 draft)仍列全部 3 条。
+ let all = sqlx::query_as::<_, PrototypePrompt>("SELECT * FROM prototype_prompts WHERE project_id=?")
+ .bind("p1").fetch_all(&db).await.unwrap();
+ assert_eq!(all.len(), 3);
+ }
+}
diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs
index 936f706..678300a 100644
--- a/src-tauri/src/commands/system.rs
+++ b/src-tauri/src/commands/system.rs
@@ -90,8 +90,9 @@ pub struct UpdateConcurrencyConfig {
pub idle_timeout_min: Option,
/// 负载感知入场:系统 1 分钟负载 > factor×nproc 时暂缓启动新 agent(0 = 关闭)。
pub max_load_factor: Option,
- /// 合并门构建池:任意时刻最多并发的编译/测试数。
- pub build_slots: Option,
+ /// 核预算令牌上限(Tier 2):合并门测试逐 check 按权重借核令牌,总占用 ≤ 此数(以核计)。
+ #[serde(alias = "build_slots")]
+ pub cpu_permits: Option,
/// cgroup CPU 预算(占总核数百分比,0 = 关闭;仅 Linux 生效)。
pub cpu_budget_pct: Option,
/// 出站 LLM 并发上限:同时打到 LLM 服务商的请求数(防 429 限流)。
@@ -112,12 +113,23 @@ pub struct ConcurrencyConfig {
pub idle_timeout_min: u64,
/// 负载感知入场阈值(factor×nproc,0 = 关闭)。
pub max_load_factor: f64,
- /// 合并门构建池大小。
- pub build_slots: usize,
+ /// 核预算令牌上限(Tier 2,以核计)。
+ pub cpu_permits: usize,
/// cgroup CPU 预算(% × nproc,0 = 关闭)。
pub cpu_budget_pct: u64,
/// 出站 LLM 并发上限(防 429 限流)。
pub llm_max_concurrency: usize,
+ // ── 可观测收敛信号(文档 §6;只读,前端展示,非配置项)────────────────────────
+ /// 逻辑 CPU 数(核预算的分母)。
+ pub nproc: usize,
+ /// 核预算当前可用令牌数:`cpu_permits - available` 近似即时占用。
+ pub cpu_permits_available: usize,
+ /// 核预算队列深度:被阻塞等待令牌的验证相数(背压信号;`> cpu_permits` 即触发准入降速)。
+ pub cpu_permits_queue_depth: usize,
+ /// 1 分钟系统负载:稳态应 ≈ nproc。`None` = 非 Linux 不可读。
+ pub load_avg_1m: Option,
+ /// cgroup 累计被限速周期数(`nr_throttled`):稳态应几乎不增长。`None` = 未启用 / 非 Linux。
+ pub cgroup_throttled_periods: Option,
}
/// 代码 agent 超时默认值(分钟)。墙钟是硬上限兜底,空闲超时是抓卡死的主闸。
@@ -131,25 +143,30 @@ pub const DEFAULT_IDLE_TIMEOUT_MIN: u64 = 8;
pub const DEFAULT_IDLE_TIMEOUT_MIN: u64 = 0;
/// 负载感知入场默认阈值:负载 > 1.5×nproc 才暂缓——只在真过载时踩刹车,正常不挡。
pub const DEFAULT_MAX_LOAD_FACTOR: f64 = 1.5;
-/// 合并门构建池默认并发:2 个编译/测试同时跑(每个可吃多核,故不宜大)。
-pub const DEFAULT_BUILD_SLOTS: usize = 2;
-/// CPU 预算默认 0=关(cgroup 依环境,显式开启更安全;建议 Linux 上设 70~80)。
-pub const DEFAULT_CPU_BUDGET_PCT: u64 = 0;
+/// 核预算令牌上限(Tier 2):合并门测试逐 check 按权重借核令牌,任意时刻总占用 ≤ 此数。
+/// 默认 = nproc(见 `load_cpu_permits`),以「核」为单位,取代旧「构建池 CR 计数」。
+pub const CPU_PERMITS_MAX: usize = 64;
+/// CPU 预算默认 90%×nproc:cgroup v2 硬兜底默认开启,把所有 code agent 进程组 + 合并门
+/// 测试的【总 CPU】封顶在 90%×nproc,留 ~10% 给控制平面/webview,封住 N 个并行 claude -p
+/// 内部 rustc/tsc 突发把机器打满。非 Linux / 无 cgroup v2 委派时 `cpubudget::init` 自动优雅
+/// 降级为空操作(回退到 nice + 负载闸 + max_slots),零副作用。UI 仍可改回 0 显式关闭。
+pub const DEFAULT_CPU_BUDGET_PCT: u64 = 90;
/// 出站 LLM 并发上限默认值:限制同时打到 LLM 服务商的请求数,防批量任务(如一次分析 50 条
/// 需求)瞬间数十并发触发 429 限流。保守取 4,可在「并发控制」按服务商配额调高。
pub const DEFAULT_LLM_CONCURRENCY: usize = 4;
-/// 读取合并门构建池大小(clamp [1, 32])。
-pub async fn load_build_slots(db: &crate::db::Db) -> usize {
+/// 读取核预算令牌上限(Tier 2,clamp [1, CPU_PERMITS_MAX])。无值时默认 = nproc——以「核」
+/// 为单位,让合并门的验证并发自动贴合机器核数(取代旧 build_slots 的固定 2)。
+pub async fn load_cpu_permits(db: &crate::db::Db) -> usize {
let v: Option<(String,)> =
- sqlx::query_as("SELECT value FROM app_settings WHERE key='execution.build_slots'")
+ sqlx::query_as("SELECT value FROM app_settings WHERE key='execution.cpu_permits'")
.fetch_optional(db)
.await
.ok()
.flatten();
v.and_then(|(s,)| s.parse::().ok())
- .unwrap_or(DEFAULT_BUILD_SLOTS)
- .clamp(1, 32)
+ .unwrap_or_else(crate::core::cpu_permits::nproc)
+ .clamp(1, CPU_PERMITS_MAX)
}
/// 读取 CPU 预算百分比(clamp [0, 100],0=关)。
@@ -372,6 +389,7 @@ pub async fn get_badge_counts(state: State<'_, AppState>) -> Result COALESCE(r.read_at, '1970-01-01')",
)
.fetch_one(&state.db)
@@ -722,18 +740,19 @@ pub async fn update_concurrency_config(
.await
.map_err(|e| e.to_string())?;
}
- if let Some(b) = payload.build_slots {
+ if let Some(b) = payload.cpu_permits {
+ let n = b.clamp(1, CPU_PERMITS_MAX);
sqlx::query(
"INSERT INTO app_settings (key, value, updated_at)
- VALUES ('execution.build_slots', ?, datetime('now'))
+ VALUES ('execution.cpu_permits', ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
)
- .bind(b.clamp(1, 32).to_string())
+ .bind(n.to_string())
.execute(&state.db)
.await
.map_err(|e| e.to_string())?;
- // 即时调整构建池容量(与 max_slots/cpu_budget 一致,无需重启)。
- crate::state::set_build_slots(b.clamp(1, 32));
+ // 即时调整核预算令牌上限(与 max_slots/cpu_budget 一致,无需重启)。
+ crate::core::cpu_permits::set_permits(n);
}
if let Some(p) = payload.cpu_budget_pct {
sqlx::query(
@@ -765,9 +784,14 @@ pub async fn update_concurrency_config(
let (wall_secs, idle_secs) = load_execution_limits(&state.db).await;
let max_load_factor = load_max_load_factor(&state.db).await;
- let build_slots = load_build_slots(&state.db).await;
+ let cpu_permits = load_cpu_permits(&state.db).await;
let cpu_budget_pct = load_cpu_budget_pct(&state.db).await;
let llm_max_concurrency = load_llm_concurrency(&state.db).await;
+ let nproc = crate::core::cpu_permits::nproc();
+ let cpu_permits_available = crate::core::cpu_permits::available();
+ let cpu_permits_queue_depth = crate::core::cpu_permits::queue_depth();
+ let load_avg_1m = crate::core::reaper::load_avg_1m();
+ let cgroup_throttled_periods = crate::core::cpubudget::throttle_stats().map(|(n, _)| n);
Ok(ConcurrencyConfig {
active_slots: status.active_slots,
max_slots: status.max_slots,
@@ -778,9 +802,14 @@ pub async fn update_concurrency_config(
timeout_min: wall_secs / 60,
idle_timeout_min: idle_secs / 60,
max_load_factor,
- build_slots,
+ cpu_permits,
cpu_budget_pct,
llm_max_concurrency,
+ nproc,
+ cpu_permits_available,
+ cpu_permits_queue_depth,
+ load_avg_1m,
+ cgroup_throttled_periods,
})
}
@@ -791,9 +820,14 @@ pub async fn get_concurrency_config(
let status = state.concurrency.status();
let (wall_secs, idle_secs) = load_execution_limits(&state.db).await;
let max_load_factor = load_max_load_factor(&state.db).await;
- let build_slots = load_build_slots(&state.db).await;
+ let cpu_permits = load_cpu_permits(&state.db).await;
let cpu_budget_pct = load_cpu_budget_pct(&state.db).await;
let llm_max_concurrency = load_llm_concurrency(&state.db).await;
+ let nproc = crate::core::cpu_permits::nproc();
+ let cpu_permits_available = crate::core::cpu_permits::available();
+ let cpu_permits_queue_depth = crate::core::cpu_permits::queue_depth();
+ let load_avg_1m = crate::core::reaper::load_avg_1m();
+ let cgroup_throttled_periods = crate::core::cpubudget::throttle_stats().map(|(n, _)| n);
Ok(ConcurrencyConfig {
active_slots: status.active_slots,
@@ -805,9 +839,14 @@ pub async fn get_concurrency_config(
timeout_min: wall_secs / 60,
idle_timeout_min: idle_secs / 60,
max_load_factor,
- build_slots,
+ cpu_permits,
cpu_budget_pct,
llm_max_concurrency,
+ nproc,
+ cpu_permits_available,
+ cpu_permits_queue_depth,
+ load_avg_1m,
+ cgroup_throttled_periods,
})
}
diff --git a/src-tauri/src/core/asr_realtime.rs b/src-tauri/src/core/asr_realtime.rs
index c17f3eb..a3ac1df 100644
--- a/src-tauri/src/core/asr_realtime.rs
+++ b/src-tauri/src/core/asr_realtime.rs
@@ -7,17 +7,17 @@
//! 协议(paraformer-realtime-v2,PCM 16k 单声道):connect → run-task → 流式发二进制音频
//! → result-generated(增量/整句) → finish-task → task-finished。
//!
-//! Tauri 耦合仅限 `AppHandle` 用于事件发射(CLAUDE.md 允许的唯一例外)。
+//! 事件发射经 `EventSink` 抽象(不再直接持 `AppHandle`),对齐 core 层「纯 Rust、
+//! 不依赖 Tauri 类型」的后端独立化愿景。
use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
-use tauri::AppHandle;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
-use crate::core::event::{self, AppEvent};
+use crate::core::event::{self, AppEvent, EventSink};
use crate::db::Db;
const DASHSCOPE_WS: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/";
@@ -92,14 +92,13 @@ async fn open_ws(db: &Db) -> Result<(WsStream, Value, String), String> {
/// 后台任务负责:run-task 握手 → 转发音频 → 解析结果发事件 → finish。
pub async fn start_session(
db: &Db,
- app: &AppHandle,
+ sink: std::sync::Arc,
session_id: String,
) -> Result, String> {
let (ws, run_task, task_id) = open_ws(db).await?;
let (tx, rx) = mpsc::unbounded_channel::();
- let app = app.clone();
tokio::spawn(async move {
- if let Err(e) = drive(ws, rx, run_task, task_id, &app, &session_id).await {
+ if let Err(e) = drive(ws, rx, run_task, task_id, sink.as_ref(), &session_id).await {
tracing::warn!("[asr] 会话 {} 结束:{}", session_id, e);
}
});
@@ -177,7 +176,7 @@ async fn drive(
mut rx: mpsc::UnboundedReceiver,
run_task: Value,
task_id: String,
- app: &AppHandle,
+ sink: &dyn EventSink,
session_id: &str,
) -> Result<()> {
let (mut write, mut read) = ws.split();
@@ -203,7 +202,7 @@ async fn drive(
}
},
msg = read.next() => match msg {
- Some(Ok(Message::Text(t))) if handle_event(&t, app, session_id) => {
+ Some(Ok(Message::Text(t))) if handle_event(&t, sink, session_id) => {
break;
}
Some(Ok(Message::Close(_))) | None => break,
@@ -216,7 +215,7 @@ async fn drive(
}
/// 解析一条服务端 JSON 事件;返回 true 表示会话应终止。
-fn handle_event(text: &str, app: &AppHandle, session_id: &str) -> bool {
+fn handle_event(text: &str, sink: &dyn EventSink, session_id: &str) -> bool {
let Ok(v) = serde_json::from_str::(text) else { return false };
match v["header"]["event"].as_str() {
Some("result-generated") => {
@@ -225,7 +224,7 @@ fn handle_event(text: &str, app: &AppHandle, session_id: &str) -> bool {
if !out.is_empty() {
let is_final = sentence["sentence_end"].as_bool().unwrap_or(false);
event::emit(
- app,
+ sink,
AppEvent::AsrResult {
session_id: session_id.to_string(),
text: out,
diff --git a/src-tauri/src/core/context.rs b/src-tauri/src/core/context.rs
new file mode 100644
index 0000000..dcd0604
--- /dev/null
+++ b/src-tauri/src/core/context.rs
@@ -0,0 +1,851 @@
+//! 上下文基质 L2(数据侧)——方法论平台/基质设计 §3、§4.1。
+//!
+//! 把 L1 既有各表投影为统一的 [`ContextItem`](薄索引,不搬正文),提供三种访问里的
+//! 前两种地基:**枚举/检索**([`list`],只回元数据)与**稳定引用登记**([`register`])。
+//! 正文懒取(access #2)与装配引擎(L3)在后续批次(B1/B2)叠加。
+//!
+//! 铁律(对齐 CLAUDE.md 后端独立化愿景):本模块**纯 Rust**,不引用 `tauri::*`;
+//! 外部来源(trust=external_untrusted)回灌上下文前必过 `security::has_obvious_injection`。
+
+use crate::db::Db;
+use crate::models::context_item::ContextItem;
+use anyhow::Result;
+
+/// 来源类型全集(基质设计 §3.2)。自由字符串,此处仅登记「约定值」便于统一引用;
+/// 新增来源 = 加一个常量 + 一个投影适配器,不动既有子系统。
+pub mod source_kind {
+ // —— 静态文件类(已在旧上下文装配路径内) ——
+ pub const FILE_PRIORITY: &str = "file_priority"; // claude.md / agents.md
+ pub const FILE_PINNED: &str = "file_pinned"; // conversation_project_context
+ pub const WORKSPACE_DOC: &str = "workspace_doc"; // .autoforge/docs
+ pub const WORKSPACE_SPEC: &str = "workspace_spec"; // .autoforge/specs
+ pub const WORKSPACE_DELIVERABLE: &str = "workspace_deliverable"; // .autoforge/deliverables
+ pub const PROJECT_SPEC: &str = "project_spec"; // project_specs
+
+ // —— 物料 + 过程信息(当前游离在上下文之外的关键缺口,§2.2) ——
+ pub const MATERIAL: &str = "material"; // material_files
+ pub const CHAT_MESSAGE: &str = "chat_message"; // messages
+ pub const AGENT_OUTPUT: &str = "agent_output"; // conversation_task_runs
+ pub const CODE_AGENT_LOG: &str = "code_agent_log"; // code_agent_run_logs
+ pub const LLM_TRACE: &str = "llm_trace"; // llm_traces
+ pub const ISSUE: &str = "issue"; // issues
+ pub const CR_REVIEW: &str = "cr_review"; // change_requests + admin_decisions
+ pub const INCUBATOR_DRAFT: &str = "incubator_draft"; // blueprint_*
+ pub const ATTACHMENT: &str = "attachment"; // conversation_attachments
+
+ // —— 全量覆盖新增来源(万物可引 · 实施契约 §5) ——
+ pub const SECURITY_AUDIT: &str = "security_audit"; // security_audits
+ pub const TEST_SESSION: &str = "test_session"; // test_sessions
+ pub const SCAN_FINDING: &str = "scan_finding"; // scan_findings
+ pub const DEPLOYMENT: &str = "deployment"; // deployments
+ pub const DELIVERY_ARTIFACT: &str = "delivery_artifact"; // delivery_artifacts
+ pub const WORKTREE_SESSION: &str = "worktree_session"; // worktree_sessions
+ pub const PROTOTYPE_PROMPT: &str = "prototype_prompt"; // prototype_prompts
+ pub const PROJECT_META: &str = "project_meta"; // .autoforge/claude.md · agents.md
+ pub const CFG_AGENT: &str = "cfg_agent"; // agents(脱敏)
+ pub const CFG_CODE_AGENT: &str = "cfg_code_agent"; // code_agents(脱敏)
+ pub const CFG_MCP: &str = "cfg_mcp"; // mcp_servers(脱敏,仅非密文字段)
+
+ // —— 外部不可信来源(trust=external_untrusted,必过注入闸) ——
+ pub const MCP_RESULT: &str = "mcp_result";
+ pub const WEB_RESULT: &str = "web_result";
+}
+
+/// 信任级别(基质设计 §3.3)。内部产生=trusted;外部来源=external_untrusted,
+/// 回灌上下文前必过 `has_obvious_injection`。
+pub mod trust {
+ pub const TRUSTED: &str = "trusted";
+ pub const EXTERNAL_UNTRUSTED: &str = "external_untrusted";
+}
+
+/// 稳定引用:由 `source_kind` + `source_id` 派生,保证同一来源条目只对应一个 id。
+pub fn stable_id(source_kind: &str, source_id: &str) -> String {
+ format!("{source_kind}:{source_id}")
+}
+
+/// 一条待登记的上下文条目(借用字段,避免不必要拷贝)。
+pub struct NewContextItem<'a> {
+ pub project_id: &'a str,
+ pub source_kind: &'a str,
+ pub source_id: &'a str,
+ pub title: &'a str,
+ pub origin_stage: &'a str,
+ pub origin_actor: &'a str,
+ pub content_ref: &'a str,
+ pub size_hint: i64,
+ pub trust: &'a str,
+ /// 自由标签 JSON 数组字符串(如 `["prd","design"]`);空则传 `"[]"`。
+ pub labels: &'a str,
+}
+
+impl<'a> NewContextItem<'a> {
+ /// 最常见形态:内部可信来源,无阶段/标签的快捷构造。
+ pub fn trusted(
+ project_id: &'a str,
+ source_kind: &'a str,
+ source_id: &'a str,
+ title: &'a str,
+ content_ref: &'a str,
+ ) -> Self {
+ Self {
+ project_id,
+ source_kind,
+ source_id,
+ title,
+ origin_stage: "",
+ origin_actor: "",
+ content_ref,
+ size_hint: 0,
+ trust: trust::TRUSTED,
+ labels: "[]",
+ }
+ }
+}
+
+/// 幂等登记一条上下文条目到薄索引。同一来源(source_kind+source_id)再次登记时
+/// **刷新**元数据(title/content_ref/size_hint/labels/updated_at),不产生重复行。
+/// 正文不入库——只登记「有这么一条、在哪取、多大、可不可信」。
+pub async fn register(db: &Db, item: NewContextItem<'_>) -> Result<()> {
+ let id = stable_id(item.source_kind, item.source_id);
+ sqlx::query(
+ "INSERT INTO context_index
+ (id, project_id, source_kind, source_id, title, origin_stage, origin_actor,
+ content_ref, size_hint, trust, labels, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
+ ON CONFLICT(id) DO UPDATE SET
+ title=excluded.title,
+ origin_stage=excluded.origin_stage,
+ origin_actor=excluded.origin_actor,
+ content_ref=excluded.content_ref,
+ size_hint=excluded.size_hint,
+ trust=excluded.trust,
+ labels=excluded.labels,
+ updated_at=datetime('now')",
+ )
+ .bind(&id)
+ .bind(item.project_id)
+ .bind(item.source_kind)
+ .bind(item.source_id)
+ .bind(item.title)
+ .bind(item.origin_stage)
+ .bind(item.origin_actor)
+ .bind(item.content_ref)
+ .bind(item.size_hint)
+ .bind(item.trust)
+ .bind(item.labels)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+/// 移除一条已登记条目(原始来源被删时同步清理索引)。
+pub async fn deregister(db: &Db, source_kind: &str, source_id: &str) -> Result<()> {
+ let id = stable_id(source_kind, source_id);
+ sqlx::query("DELETE FROM context_index WHERE id=?")
+ .bind(&id)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+/// 枚举一个项目的候选上下文条目(只回元数据,不回正文;access #1)。
+/// `kinds` 为空则不按来源类型过滤;结果按产生时间倒序,`limit` 为返回总上限。
+///
+/// **pull 模型**(《全量上下文基质·万物可引》契约 §3):从数据活查(provider 层枚举全量来源),
+/// 而非只读 `context_index`。`context_index` 仅作可选缓存(现由 register 钩子预热,此处不依赖)。
+/// 总预算 `limit` 摊到各命中来源,避免单一大表淹没其余来源。
+pub async fn list(
+ db: &Db,
+ project_id: &str,
+ kinds: &[&str],
+ limit: i64,
+ query: Option<&str>,
+) -> Result> {
+ use crate::core::context_providers as cp;
+ let query = query.map(str::trim).filter(|q| !q.is_empty());
+ let repo = project_repo_path(db, project_id).await;
+ let n_sources = if kinds.is_empty() {
+ (cp::SOURCES.len() + cp::FILE_SOURCES.len()) as i64
+ } else {
+ kinds.len() as i64
+ };
+ // 每来源软上限:总预算摊平,但至少 10、至多 limit(单来源筛选时给满)。
+ // 搜索态它同时是防单一吵闹来源垄断结果的配额。
+ let per_source = (limit / n_sources.max(1)).clamp(10, limit.max(10));
+ let mut items =
+ cp::enumerate_all(db, project_id, kinds, repo.as_deref(), per_source, query).await?;
+
+ // 叠加 context_index 缓存(register 钩子预热 / 历史条目):provider 未覆盖的按 id 补入,
+ // provider 结果优先(活查是真源,缓存仅兜底/加速)。
+ let seen: std::collections::HashSet = items.iter().map(|i| i.id.clone()).collect();
+ if let Ok(cached) = list_cached(db, project_id, kinds, limit, query).await {
+ for c in cached {
+ if !seen.contains(&c.id) {
+ items.push(c);
+ }
+ }
+ }
+ match query {
+ // 搜索态:kind 先验降权——产物类置前、过程类沉底(组内仍时间倒序)。
+ Some(_) => items.sort_by(|a, b| {
+ kind_rank(&a.source_kind)
+ .cmp(&kind_rank(&b.source_kind))
+ .then_with(|| b.created_at.cmp(&a.created_at))
+ }),
+ // 枚举态:维持全局时间倒序(现状行为,装配路径依赖它)。
+ None => items.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
+ }
+ items.truncate(limit.max(0) as usize);
+ Ok(items)
+}
+
+/// 只读 `context_index` 缓存(旧 push 路径的查询;现作 [`list`] 的缓存叠加层)。
+async fn list_cached(
+ db: &Db,
+ project_id: &str,
+ kinds: &[&str],
+ limit: i64,
+ query: Option<&str>,
+) -> Result> {
+ let mut sql = String::from("SELECT * FROM context_index WHERE project_id=?");
+ if !kinds.is_empty() {
+ sql.push_str(" AND source_kind IN (");
+ sql.push_str(&vec!["?"; kinds.len()].join(","));
+ sql.push(')');
+ }
+ if query.is_some() {
+ sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR labels LIKE ? ESCAPE '\\')");
+ }
+ sql.push_str(" ORDER BY created_at DESC, rowid DESC LIMIT ?");
+ let mut q = sqlx::query_as::<_, ContextItem>(&sql).bind(project_id);
+ for k in kinds {
+ q = q.bind(*k);
+ }
+ if let Some(qs) = query {
+ let pat = crate::core::context_providers::like_pattern(qs);
+ q = q.bind(pat.clone()).bind(pat);
+ }
+ q = q.bind(limit);
+ Ok(q.fetch_all(db).await?)
+}
+
+/// 解析项目本地仓库路径(文件来源 provider 需要;无仓库则回 None)。
+pub(crate) async fn project_repo_path(db: &Db, project_id: &str) -> Option {
+ sqlx::query_as::<_, (Option,)>("SELECT repo_path FROM projects WHERE id=?")
+ .bind(project_id)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ .and_then(|(v,)| v)
+ .filter(|s| !s.is_empty())
+}
+
+/// 取单条上下文条目元数据(供显式 ref 定位)。
+///
+/// pull 模型下:先查 `context_index` 缓存(若被 register 预热);缓存 miss 时从稳定 id
+/// (`:`)**反构最小条目**——只要 kind 是已知 provider 来源即可,
+/// 后续 `fetch_content` 按 kind+source_id 懒取正文。这样显式 ref 无需依赖缓存即可解析。
+pub async fn get(db: &Db, id: &str) -> Result