diff --git a/cat-cafe-skills/guide-authoring/SKILL.md b/cat-cafe-skills/guide-authoring/SKILL.md
new file mode 100644
index 000000000..8a1f6cacb
--- /dev/null
+++ b/cat-cafe-skills/guide-authoring/SKILL.md
@@ -0,0 +1,193 @@
+---
+name: guide-authoring
+description: >
+ 标准引导流程设计 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。
+ Use when: 新建引导流程、添加场景引导、维护 Guide Catalog、编写引导 YAML。
+ Not for: 使用引导(用户侧)、Guide Engine 代码实现(用 tdd)、视觉设计(用 pencil-design)。
+ Output: Flow YAML + tag-manifest 更新 + registry 注册 + CI 校验通过。
+triggers:
+ - "新建引导"
+ - "添加场景引导"
+ - "写引导流程"
+ - "guide authoring"
+ - "引导 YAML"
+---
+
+# Guide Authoring
+
+为 Console 已有功能编写引导流程的标准 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。
+
+## When to Use
+
+- 需要为 Console 已有功能新增一条可触发的引导流程
+- 需要维护 `guides/flows/*.yaml`、`guides/tag-manifest.yaml`、`guides/registry.yaml`
+- 需要给已有功能补引导标签或补 registry 发现规则
+
+**Not for**:
+- 用户正在实际使用引导流程时的交互处理,用 `guide-interaction`
+- Guide Engine / callback route / overlay 的代码实现与 bug 修复,用 `tdd`
+- 纯视觉稿、动效或高保真设计稿,用 `pencil-design`
+
+## 核心知识
+
+| 原则 | 说明 |
+|------|------|
+| 编排即产品 | Flow YAML 是终态产物,不是脚手架 |
+| 页面零侵入 | 只加 `data-guide-id` 标签,不改业务逻辑 |
+| 自动推进 | 用户操作即推进,无手动导航按钮(v2 KD-9) |
+| 平台内聚焦 | 聚焦 Console 已有功能的引导(KD-13),外部平台配置改为独立页签 |
+
+**前置依赖**:F150 Guide Engine Phase A 已验收。
+
+## 流程
+
+### Step 1: 场景识别
+
+确认需要引导的场景,产出"场景卡片":
+
+```yaml
+# 场景卡片
+scene_id: api-provider-setup
+scene_name: 配置 API Provider
+target_user: 新部署用户 / 需要配置 LLM 的团队
+pain_point: Provider 配置字段多,不同 provider 参数差异大
+complexity: medium # low / medium / high
+estimated_steps: 4
+estimated_time: 3min
+related_features: [F150]
+```
+
+**判断标准**:
+- complexity=high → 必须做引导
+- complexity=medium → 评估用户卡点频率再定
+- complexity=low → 不做引导,文档即可
+
+### Step 2: 步骤拆分 + YAML 编排
+
+按 v2 自动推进模式编排,4 种 advance mode:
+
+| advance | 用途 | 说明 |
+|---------|------|------|
+| `click` | 点击目标元素 | 用户点击后自动前进 |
+| `visible` | 目标元素出现 | 页面切换/展开后自动前进 |
+| `input` | 输入填充 | 用户填写输入框后前进 |
+| `confirm` | 操作确认 | 需要 `guide:confirm` 事件触发(如保存成功) |
+
+**Flow YAML 模板**(v2 schema):
+
+```yaml
+id: {scene_id}
+name: {scene_name}
+description: {一句话描述}
+
+steps:
+ - id: step-1
+ target: "namespace.element" # data-guide-id 值
+ tips: "点击这里开始配置" # 引导文案
+ advance: click # click / visible / input / confirm
+
+ - id: step-2
+ target: "namespace.form-field"
+ tips: "填写 API 密钥"
+ advance: input
+
+ - id: step-final
+ target: "namespace.save-button"
+ tips: "点击保存完成配置"
+ advance: confirm # 保存成功后 guide:confirm 触发
+```
+
+**编排规则**:
+- 每个 flow 必须有退出路径(HUD 退出按钮始终可用)
+- target 值必须匹配 `data-guide-id`,命名空间式(如 `hub.trigger`)
+- target 必须通过 whitelist:`/^[a-zA-Z0-9._-]+$/`
+- 最后一步建议用 `confirm` 类型,确保操作真正成功后才完成
+- 全局 Esc 键已禁用(KD-14),防止误退出
+
+### Step 3: 元素标签标注
+
+给涉及的前端元素添加 `data-guide-id`:
+
+```tsx
+// 命名规则:{页面}.{区域}.{元素}
+
+
+```
+
+**标签命名约定**:
+- 用点号分层,语义而非位置
+- 避免 CSS class 名、索引号
+- 标签一旦被 flow 引用即为契约,删改需走 CI 门禁
+
+**产出**:更新 `guides/tag-manifest.yaml`(CI 用于契约校验):
+
+```yaml
+# guides/tag-manifest.yaml
+tags:
+ hub.trigger: { page: "/hub", component: "CatCafeHub.tsx" }
+ cats.add-member: { page: "/hub/cats", component: "HubCatsTab.tsx" }
+```
+
+### Step 4: 注册到 Guide Registry
+
+在 `guides/registry.yaml` 添加场景条目:
+
+```yaml
+- id: api-provider-setup
+ name: 配置 API Provider
+ keywords: [api, provider, 配置, llm, api-key, 模型]
+ entry_page: /hub/settings/providers
+ estimated_time: 3min
+ flow_file: guides/flows/api-provider-setup.yaml
+ priority: P1
+```
+
+**关键词设计原则**:
+- 覆盖中英文同义词
+- 包含用户可能的自然表达
+- 不要太泛(避免误匹配)
+
+### Step 5: CI 契约测试
+
+确保以下校验全部通过(对应 AC-S3):
+- [ ] Flow schema 合法(step 字段 + advance 类型)
+- [ ] 所有 `target` 在 tag-manifest.yaml 中存在
+- [ ] 至少有退出路径(HUD 退出按钮 — 引擎内置,无需手动添加)
+
+### Step 6: 端到端验证
+
+1. 启动 dev 环境
+2. 在聊天中触发引导(说匹配关键词)
+3. 走完全流程:每步高亮正确 → 操作后自动推进 → 完成回调生效
+4. 测试异常路径:退出 → 刷新 → 目标元素不存在时的 locating 行为
+5. 确认完成后猫猫收到 completion 通知
+
+## Quick Reference
+
+| 要做什么 | 文件 | 说明 |
+|---------|------|------|
+| 写新引导流程 | `guides/flows/{id}.yaml` | 按 Step 2 模板 |
+| 加元素标签 | 前端组件 + `guides/tag-manifest.yaml` | 按 Step 3 命名约定 |
+| 注册发现 | `guides/registry.yaml` | 按 Step 4 |
+| 验证 | CI gate + 手动 E2E | 按 Step 5-6 |
+
+## Common Mistakes
+
+| 错误 | 后果 | 修复 |
+|------|------|------|
+| 标签用 CSS class 名 | UI 重构后引导失效 | 用语义命名 |
+| 忘记注册 registry | 猫猫查不到引导 | Step 4 不可跳过 |
+| 最后一步不用 confirm | 操作未成功就完成 | 涉及保存/提交的最后一步必须 confirm |
+| 关键词太泛 | 误匹配其他场景 | 用具体术语 |
+| 跳过 E2E 验证 | 线上引导卡死 | Step 6 是发布前必做 |
+
+## 和其他 Skill 的区别
+
+- `feat-lifecycle`:管理 Feature 生命周期 — guide-authoring 是写 **引导流程文档** 的 SOP
+- `tdd`:代码的测试驱动 — guide-authoring 是 **YAML 编排** 的质量纪律
+- `pencil-design`:出设计稿 — guide-authoring 定义引导 **逻辑和数据**,pencil 出 **视觉效果**
+
+## 下一步
+
+- 引导流程写完 → `tdd` 验证 YAML + 标签
+- 验证通过 → `quality-gate` → `request-review`
diff --git a/cat-cafe-skills/guide-interaction/SKILL.md b/cat-cafe-skills/guide-interaction/SKILL.md
new file mode 100644
index 000000000..98a819b3e
--- /dev/null
+++ b/cat-cafe-skills/guide-interaction/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: guide-interaction
+description: >
+ 场景引导交互模式:匹配到引导流程后,向用户提供交互式选择卡片,
+ 处理用户选择(开始引导/步骤概览/跳过),启动前端引导 overlay。
+ Use when: 系统注入了 Guide Available(自动触发,不需要手动加载)。
+ Not for: 没有匹配到引导流程的普通对话。
+triggers:
+ - "引导流程"
+ - "guide"
+---
+
+# Guide Interaction — 场景引导交互模式
+
+## 你的角色
+
+你是场景引导助手。当系统检测到用户的问题匹配了一个已有的交互引导流程时,
+你负责用简短自然的方式告知用户,并提供交互选项让用户决定下一步。
+
+**核心原则**:
+- 不要直接给出长篇教程或步骤列表
+- 先提供选择,尊重用户意愿
+- 回复简短自然,像对话而非说明书
+
+## 系统注入格式
+
+当有匹配的引导流程时,系统会在你的 prompt 中注入:
+
+```
+🧭 Guide Available: thread={threadId} id={guideId} name={guideName} time={estimatedTime} status={status}
+→ Load guide-interaction skill and act per current status.
+```
+
+从这行读取 `guideId`、`guideName`、`estimatedTime`、`status`。
+
+## 工具速查
+
+| 动作 | MCP 工具 | 参数 |
+|------|----------|------|
+| 持久化引导状态 | `cat_cafe_update_guide_state` | `threadId`, `guideId`, `status`, `currentStep?` |
+| 发送交互选择卡片 | `cat_cafe_create_rich_block` | `block=` |
+| 启动前端引导 overlay | `cat_cafe_start_guide` | `guideId` |
+| 解析用户意图匹配 | `cat_cafe_guide_resolve` | `intent` |
+| 控制引导进度 | `cat_cafe_guide_control` | `action` (next/back/skip/exit) |
+
+**重要**:状态持久化是必须的,但进入 `active` 是例外。开始引导时必须调用 `cat_cafe_start_guide`,不要手动 `status='active'`,否则前端 overlay 不会收到完整 start side effects。
+
+## Status 驱动行为
+
+### status: offered
+
+首次向用户展示引导选项。
+
+1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='offered')` 持久化状态
+2. 写一句自然的话,告知用户你找到了匹配的引导流程
+ - 示例:「我找到了「{guideName}」的交互引导流程,大约需要 {estimatedTime}。」
+ - 可以根据对话上下文微调措辞,不必死板照搬
+3. 调用 `cat_cafe_create_rich_block`,`block` 参数传入以下 JSON 字符串:
+
+```json
+{
+ "id": "guide-offer-{guideId}-{取自系统注入的threadId的后8位}",
+ "kind": "interactive",
+ "v": 1,
+ "interactiveType": "select",
+ "title": "我找到了「{guideName}」引导流程(约 {estimatedTime})。要现在开始吗?",
+ "options": [
+ { "id": "start", "label": "开始引导(推荐)", "emoji": "🚀" },
+ { "id": "preview", "label": "先看步骤概览", "emoji": "📋" },
+ { "id": "skip", "label": "暂不需要", "emoji": "⏭️" }
+ ],
+ "messageTemplate": "引导流程:{selection}"
+}
+```
+
+4. **禁止**在这个阶段直接给出步骤教程
+5. 等待用户在选项卡中做出选择
+
+### status: awaiting_choice
+
+用户已看到选项卡但尚未选择,或刷新了页面。
+- 不要重复发送选项卡
+- 用一句话提醒:「之前找到了「{guideName}」引导流程,你要开始吗?」
+
+### 用户选择后的处理
+
+用户点击选项后,系统会将选择作为消息发回(格式:`引导流程:{选项label}`)。
+根据选择内容执行:
+
+**用户选了「开始引导」**:
+1. 调用 `cat_cafe_start_guide(guideId)` 启动前端引导 overlay
+2. `cat_cafe_start_guide` 会同时完成 `offered/awaiting_choice → active` 的状态更新和 socket side effects
+3. 回复一句鼓励的话,如「引导已启动,跟着页面上的提示一步步来就好!遇到问题随时问我。」
+
+**用户选了「先看步骤概览」**:
+1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='awaiting_choice')` 标记已看到
+2. 调用 `cat_cafe_guide_resolve(intent={guideName})` 获取步骤信息
+3. 用 3-5 条简要列出主要步骤
+4. 在最后问用户是否要开始引导
+
+**用户选了「暂不需要」**:
+1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='cancelled')` 标记取消
+2. 简短回复:「好的,有需要随时说。」
+3. 如果用户原本有其他问题,继续回答那个问题
+
+### status: active
+
+引导正在进行中(前端 overlay 已启动)。
+- 监听用户反馈,感知用户遇到的困难
+- 如果用户问了和当前引导步骤相关的问题,结合引导上下文回答
+- 不要重复发送引导选项卡
+- 用户请求退出时:调用 `cat_cafe_guide_control(action='exit')`
+
+### status: completed
+
+引导已完成。
+- 不再触发引导相关行为
+- 正常对话模式
+
+### status: cancelled
+
+引导已取消。
+- 不再触发引导相关行为
+- 正常对话模式
diff --git a/cat-cafe-skills/manifest.yaml b/cat-cafe-skills/manifest.yaml
index 22978ee6a..0f97fd593 100644
--- a/cat-cafe-skills/manifest.yaml
+++ b/cat-cafe-skills/manifest.yaml
@@ -38,6 +38,27 @@ skills:
sop_step: null
merged_from: ["feat-kickoff", "feat-discussion", "feat-completion"]
+ # ── 引导流程设计 ──
+ guide-authoring:
+ description: >
+ 标准引导流程设计 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。
+ Use when: 新建引导流程、添加场景引导、维护 Guide Catalog、编写引导 YAML。
+ Not for: 使用引导(用户侧)、Guide Engine 代码实现(用 tdd)、视觉设计(用 pencil-design)。
+ Output: Flow YAML + tag-manifest 更新 + registry 注册 + CI 校验通过。
+ triggers:
+ - "新建引导"
+ - "添加场景引导"
+ - "写引导流程"
+ - "guide authoring"
+ - "引导 YAML"
+ not_for:
+ - "使用引导"
+ - "Guide Engine 实现"
+ - "出设计稿"
+ output: "Flow YAML / tag-manifest / registry entry / CI green"
+ next: ["pencil-design", "tdd"]
+ sop_step: null
+
# ── 协作思考 ──
collaborative-thinking:
description: >
diff --git a/cat-cafe-skills/refs/shared-rules.md b/cat-cafe-skills/refs/shared-rules.md
index 45ea17b2c..3166fa088 100644
--- a/cat-cafe-skills/refs/shared-rules.md
+++ b/cat-cafe-skills/refs/shared-rules.md
@@ -313,14 +313,14 @@ Rebase 遇到冲突时,**必须看三个版本**(base / ours / theirs)再
**前置条件**:`merge.conflictStyle=zdiff3`(`pnpm guards:install` 自动设置)。
设置后冲突标记自动带 base 段:
-```
-<<<<<<< HEAD
+```text
+[conflict-start: HEAD]
猫 A 的代码(ours)
-||||||| base
+[base]
原始代码(改之前的样子)
-=======
+[separator]
猫 B 的代码(theirs / main)
->>>>>>> main
+[conflict-end: main]
```
**手动查看三屏**(当 zdiff3 标记不够用时):
diff --git a/docs/decisions/.gitkeep b/docs/decisions/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs/discussions/.gitkeep b/docs/discussions/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md b/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md
new file mode 100644
index 000000000..4fe355a3c
--- /dev/null
+++ b/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md
@@ -0,0 +1,95 @@
+---
+feature_ids: [F150]
+topics: [guidance-engine, ux, security, architecture]
+doc_kind: discussion
+created: 2026-03-27
+---
+
+# F150 场景引导引擎 讨论纪要
+
+**日期**: 2026-03-27 | **参与者**: 布偶猫/宪宪(opus)、缅因猫/砚砚(gpt52)、暹罗猫/烁烁(gemini25)、铲屎官
+
+## 背景
+
+铲屎官提出:Console 功能日益复杂,但入口简单,需要一套场景化引导系统。核心诉求:
+1. 页面元素加标签,通过编排文档控制引导流程,新增场景不改代码
+2. 用户通过与猫猫对话自然触发引导
+3. 复杂外部流程(如飞书对接)也纳入引导体系
+4. **猫猫能实时观测用户操作状态**,免截图,能主动诊断问题
+
+## 各方观点
+
+### 布偶猫/宪宪(架构)
+- 四层解耦架构:Element Tags → Guide Catalog (YAML) → Guide Engine (Frontend) → MCP Tools
+- 命名空间式标签 `data-guide-id="settings.auth.add-provider"`
+- 六种步骤类型:console_action / external_instruction / collect_input / verification / branch / information
+- 双向可观测模型:Guide Engine 实时上报字段状态 + 用户行为 → 猫猫感知
+- Guide Engine 自治为主 + 猫猫接管为辅
+- 新增 `guide-authoring` Cat Cafe skill
+
+### 缅因猫/砚砚(安全 + 可测性)
+- 三条安全门禁 AC-S1/S2/S3(见共识区)
+- 现有代码基础:Hub 深链、HubAddMemberWizard、事件管道已有,不是绿地项目
+- CI 契约测试:tag 存在性 + flow schema + auto_fill_from 校验 + verifier 注册
+- `observe.fields` 对 sensitive 字段只上报 `{filled, valid}`,禁止侧信道泄漏
+
+### 暹罗猫/烁烁(视觉 + UX)
+- 聚光灯遮罩:柔和边缘 + 猫咖橙呼吸灯
+- 内外步骤视觉区分:猫咖橙(内部) vs 深空灰+外部Logo(外部)
+- 沉浸式 collect_input:非模态 inline form,磁吸感呼吸效果
+- 视觉状态机:猫眼观测指示灯(正确→眯眼绿勾,错误→圆眼警示,停滞→晃动求助)
+- 跨系统胶囊 HUD:`[控制台]──[飞书]` 双端状态指示
+- 心跳验证:Webhook 握手成功 → HUD 闪橙 + 动效反馈
+- auto_fill_from "数据飞入" 微动效
+
+## 共识
+
+### 架构共识
+1. **数据驱动**:Flow YAML 编排,新场景 = 写文档 + 打标签 + 截图,不改业务代码
+2. **稳定标签**:`data-guide-id` 命名空间式,语义而非位置
+3. **六种步骤类型**:覆盖内部操作、外部指引、数据收集、自动验证、条件分支、纯信息
+4. **双向可观测**:猫猫实时感知用户状态,主动诊断,免截图
+5. **三层触发**:对话触发(主) + 主动发现 + 目录浏览
+6. **Guide Engine 自治为主**:标准流程 Engine 跑,用户卡住时猫猫接管
+
+### 安全共识(P0 硬门禁)
+- **AC-S1: Sensitive Data Containment** — sensitive 值仅服务端持有,前端只拿 secretRef,刷新后强制重填,observe 不上报长度/前缀
+- **AC-S2: Verifier Permission Boundary** — 只允许 verifierId 引用,sideEffect=true 必须 confirm:required,带 thread/user scope guard
+- **AC-S3: CI Contract Gate** — flow schema 合法性 + tag 存在性 + auto_fill_from 校验 + verifier 注册校验 + skip_if 限声明式 DSL + 退出路径
+
+### UX 共识
+- 内外步骤用色彩/图标/HUD 位置区分
+- collect_input 为非模态 inline form
+- 视觉状态机反映观测状态
+- verification 失败显示视觉自检清单(基于错误码)
+
+### 已拍板的决策
+1. `collect_input` 敏感值刷新后**不恢复**,强制重填
+2. 有副作用的 verification 按配置 `confirm: required | auto`,CI 校验 sideEffect→confirm 规则
+3. P0 `skip_if` 限声明式比较(eq/in/exists/gt/lt),禁止表达式
+
+## 分歧
+
+**无实质分歧。** 三猫从架构/安全/UX 三个维度互补,方向一致。
+
+## 否决方案
+
+| 方案 | 否决理由 |
+|------|---------|
+| Route 1: 纯前端硬编码引导 | 每加场景都改代码,维护成本高,违背"编排文档驱动"原则 |
+| Route 2: 纯 MCP 动态生成步骤 | 可控性差,DOM 漂移风险,无法做 CI 契约测试 |
+
+## 行动项
+
+| # | 行动 | 负责 | 依赖 |
+|---|------|------|------|
+| 1 | 立项 F150,写 feature 文档(含安全 AC + 视觉 AC) | 布偶猫/宪宪 | 铲屎官确认 |
+| 2 | AC-S1/S2/S3 测试矩阵草案 | 缅因猫/砚砚 | F150 文档就绪 |
+| 3 | 内外步骤视觉区分 + 非模态 collect_input 概念稿 | 暹罗猫/烁烁 | F150 文档就绪 |
+| 4 | P0 场景编排:添加成员(纯内部) + 飞书对接(跨系统) | TBD | 设计稿 + 安全 AC 就绪 |
+
+## 收敛检查
+
+1. 否决理由 → ADR?**有** → 否决 Route 1 (硬编码) 和 Route 2 (纯动态),理由记录在本纪要"否决方案"段。待立项后迁入 feature 文档 ADR 段。
+2. 踩坑教训 → lessons-learned?**没有** — 本次是新功能设计讨论,无踩坑。
+3. 操作规则 → 指引文件?**没有** — 安全规则 (AC-S1/S2/S3) 是 feature-specific AC,不是全局操作规则。
diff --git a/docs/features/.gitkeep b/docs/features/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs/features/F155-add-member-guide-ui-spec.md b/docs/features/F155-add-member-guide-ui-spec.md
new file mode 100644
index 000000000..214467304
--- /dev/null
+++ b/docs/features/F155-add-member-guide-ui-spec.md
@@ -0,0 +1,233 @@
+---
+feature_ids: [F155]
+related_features: [F099, F127]
+topics: [guidance, ui, interaction, accessibility]
+doc_kind: spec
+created: 2026-03-27
+---
+
+# F155: Add-Member Internal Guide UI Spec (Phase A)
+
+> Status: spec | Owner: 缅因猫/砚砚 (codex) | Scope: 内部场景(添加成员)
+
+## Why
+
+Pencil MCP 当前不可用(`failed to connect to running Pencil app: antigravity`)。
+为不阻塞明早演示,这份文档提供可直接实现的 UI 结构化规格。
+
+## Scope
+
+- 仅覆盖 **场景 1:添加成员**(纯内部引导)
+- 覆盖:聚光灯遮罩、HUD 步骤导航、猫眼状态指示
+- 不含:外部步骤富媒体面板(Phase B)
+
+## Component Tree
+
+```text
+GuideOverlayRoot
+├─ SpotlightMask
+│ └─ SpotlightCutout (target rect)
+├─ StepAnchorPulse (挂在目标元素附近)
+├─ GuideHUD
+│ ├─ GuideHUDHeader
+│ │ ├─ StepTitle
+│ │ ├─ StepCounter (n / total)
+│ │ └─ CatEyeIndicator
+│ ├─ GuideHUDBody
+│ │ ├─ InstructionText
+│ │ └─ ContextHint (可选)
+│ └─ GuideHUDActions
+│ ├─ PrevButton
+│ ├─ NextButton
+│ ├─ SkipButton
+│ └─ ExitButton
+└─ GuideStatusToast (非阻塞错误/降级提示)
+```
+
+## Props Contract
+
+```ts
+export type GuideObservationState =
+ | 'idle'
+ | 'active'
+ | 'success'
+ | 'error'
+ | 'verifying';
+
+export interface GuideStep {
+ id: string;
+ targetGuideId: string; // data-guide-id
+ title: string;
+ instruction: string;
+ expectedAction: 'click' | 'input' | 'select' | 'confirm';
+ canSkip?: boolean;
+}
+
+export interface GuideOverlayRootProps {
+ sessionId: string;
+ flowId: 'add-member';
+ steps: GuideStep[];
+ currentStepIndex: number;
+ observationState: GuideObservationState;
+ highlightToken: string; // guideSessionId + stepId
+ onPrev: () => void;
+ onNext: () => void;
+ onSkip: () => void;
+ onExit: () => void;
+ onRetryLocateTarget: () => void;
+}
+```
+
+## Add-Member Step Set (Internal)
+
+```yaml
+flow_id: add-member
+steps:
+ - id: open-member-overview
+ targetGuideId: cats.overview
+ expectedAction: click
+ - id: click-add-member
+ targetGuideId: cats.add-member
+ expectedAction: click
+ - id: select-client
+ targetGuideId: add-member.client
+ expectedAction: select
+ - id: select-provider-profile
+ targetGuideId: add-member.provider-profile
+ expectedAction: select
+ - id: select-model
+ targetGuideId: add-member.model
+ expectedAction: select
+ - id: confirm-create
+ targetGuideId: add-member.submit
+ expectedAction: click
+ - id: edit-member-profile
+ targetGuideId: member-editor.profile
+ expectedAction: input
+ - id: verify-member-response
+ targetGuideId: member-editor.verify
+ expectedAction: confirm
+```
+
+## `data-guide-id` Naming (Phase A Required)
+
+- `cats.overview`
+- `cats.add-member`
+- `add-member.client`
+- `add-member.provider-profile`
+- `add-member.model`
+- `add-member.submit`
+- `member-editor.profile`
+- `member-editor.verify`
+
+命名规则:`domain.section.action`,语义化,禁止位置语义(如 left/top/row1)。
+
+## State Machine
+
+```text
+hidden
+ -> ready(target found)
+ready
+ -> active(user interacting)
+active
+ -> success(step validated)
+success
+ -> ready(next step)
+active
+ -> error(target missing/validation fail)
+error
+ -> ready(retry locate)
+error
+ -> skipped(user skip)
+any
+ -> exited(user exit)
+```
+
+### Transition Rules
+
+- `ready -> active`: 用户在目标区域发生预期动作
+- `active -> success`: 当前 step 验证通过
+- `active -> error`: 8s 内未完成预期动作,或目标节点丢失
+- `error -> ready`: 重试定位成功
+- `any -> exited`: 用户点击退出
+
+## Visual Tokens
+
+```css
+:root {
+ --guide-overlay-bg: rgba(12, 16, 24, 0.62);
+ --guide-cutout-ring: #d4853a; /* 猫咖橙 */
+ --guide-cutout-shadow: rgba(212, 133, 58, 0.35);
+ --guide-hud-bg: #fffdf8;
+ --guide-hud-border: #e7dac7;
+ --guide-text-primary: #2b251f;
+ --guide-text-secondary: #6f6257;
+ --guide-success: #2f9e44;
+ --guide-error: #d94848;
+ --guide-z-overlay: 1100;
+ --guide-z-hud: 1110;
+ --guide-z-pulse: 1120;
+ --guide-radius: 14px;
+ --guide-gap: 12px;
+ --guide-motion-fast: 160ms;
+ --guide-motion-normal: 260ms;
+}
+```
+
+## Motion Spec
+
+- Cutout 跟随目标:`transform/clip-path`,`260ms ease-out`
+- Anchor Pulse:1.4s 循环,透明度 0.35 -> 0.0
+- HUD 入场:`opacity + translateY(8px)`,`160ms`
+- CatEye
+ - `idle`: 低频摆动
+ - `active`: 轻微脉冲
+ - `success`: 绿色短闪
+ - `error`: X 轴轻抖(不超过 2 次)
+ - `verifying`: 旋转 loading
+
+## Interaction and Fallback
+
+1. 定位目标失败(首次)
+- 显示 `GuideStatusToast`: “未找到当前目标,正在重试定位…”
+- 自动重试一次(300ms)
+
+2. 定位目标失败(重试后)
+- HUD 切 `error`
+- 显示两按钮:`重试定位` / `跳过此步`
+
+3. 用户停滞超时(8s)
+- HUD 显示轻提示,不强制中断
+- 保持当前步骤,允许 `下一步/跳过`
+
+4. 退出
+- 立即销毁 overlay 和 observer
+- 记录 `flowId + stepId + exitedAt`
+
+## Accessibility
+
+- 所有操作按钮必须可键盘触达
+- `Esc` 绑定 `onExit`
+- `Left/Right Arrow` 可映射上一步/下一步(可选)
+- HUD 必须提供 `aria-live="polite"` 文本更新
+- 遮罩不阻断屏幕阅读器读取 HUD 文本
+
+## Performance Guardrail
+
+- 避免频繁 layout thrash:目标 rect 读取节流到 `requestAnimationFrame`
+- MutationObserver 仅在引导会话活跃时挂载,结束必须 `disconnect`
+- 只动画 `opacity/transform`,避免昂贵属性
+
+## Acceptance Criteria (UI)
+
+- [ ] AC-UI-1: 8 个添加成员步骤均可被 Spotlight 正确定位
+- [ ] AC-UI-2: HUD 提供上一步/下一步/跳过/退出完整闭环
+- [ ] AC-UI-3: CatEye 5 态与 `GuideObservationState` 一一对应
+- [ ] AC-UI-4: 目标缺失可降级,不出现引导卡死
+- [ ] AC-UI-5: 移动端(>=390px)HUD 不遮挡主操作区
+
+## Notes for Phase A Implementation
+
+- 先接通最小链路:`data-guide-id` 查询 + overlay 渲染 + step 切换
+- 再接状态:`GuideObservationState` 与 step 验证
+- 最后补动效与降级提示
diff --git a/docs/features/F155-scene-catalog.md b/docs/features/F155-scene-catalog.md
new file mode 100644
index 000000000..45544a90f
--- /dev/null
+++ b/docs/features/F155-scene-catalog.md
@@ -0,0 +1,173 @@
+---
+feature_ids: [F155]
+topics: [guidance-engine, scenes]
+doc_kind: note
+created: 2026-03-27
+---
+
+# F155 引导场景清单
+
+> **原则**:核心引擎先做完 → P0 验收通过 → 再逐场景迭代补全。
+> 所有场景用同一套编排文件 + 元素标签体系,实现流程一致。
+
+## 实施策略
+
+```
+Phase A: 核心引擎 + P0 内部场景(添加成员)
+Phase B: 双向可观测 + P0 外部场景(飞书对接)
+后续迭代: 按优先级逐场景补全 YAML 编排 + 截图资产
+```
+
+## 场景总览
+
+### 一、成员与账户配置
+
+| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 |
+|---|------|--------|--------|--------|---------|------|
+| 1 | **添加成员** | 极高 | 否 | **P0** | HubCatEditor | 10+ 表单段(身份/路由/账号/策略/Codex 设置),新用户最常问 |
+| 2 | 配置 API Provider | 高 | 否 | P1 | HubProviderProfilesTab | 凭证管理 + 模型发现,新部署必经 |
+| 3 | 设置 Co-Creator 个人资料 | 中 | 否 | P2 | HubCoCreatorEditor | 头像/别名/品牌色,首次使用时引导 |
+
+### 二、IM 连接器对接
+
+| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 |
+|---|------|--------|--------|--------|---------|------|
+| 4 | **飞书对接** | 高 | 是 | **P0** | FeishuAdapter + HubConnectorConfigTab | 创建飞书应用 → 配权限 → 填凭证 → 配 Webhook → 验证连通 |
+| 5 | 微信个人号对接 | 高 | 是 | P1 | WeixinAdapter + WeixinQrPanel | 检查微信版本 → 打开扫一扫 → Console 生成二维码 → 扫码 → 发消息验证 → 打开微信 DM 会话 |
+| 6 | Telegram 对接 | 中 | 是 | P1 | TelegramAdapter | @BotFather 创建 Bot → 获取 Token → 填入 Console → 验证 |
+| 7 | 钉钉对接 | 高 | 是 | P1 | DingTalkAdapter | 创建企业应用 → 配 Stream 模式 → 填 AppKey/Secret → 验证 |
+| 8 | 企业微信对接 | 高 | 是 | P2 | 待实现 (F132 Phase B/C) | 依赖 F132 后续 Phase |
+
+### 三、系统功能配置
+
+| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 |
+|---|------|--------|--------|--------|---------|------|
+| 9 | 开启推送通知 | 中 | 否 | P1 | PushSettingsPanel | 浏览器权限请求 → 订阅 → 测试推送 |
+| 10 | 管理猫猫能力 | 中 | 否 | P2 | HubCapabilityTab | MCP/Skills 全局 + 按猫开关,多作用域容易误操作 |
+| 11 | 治理看板配置 | 中 | 否 | P2 | HubGovernanceTab | 多项目发现 + 同步状态管理 |
+| 13 | 权限白名单/命令管理员配置 | 中 | 否 | P1 | HubPermissionsTab | 安全边界入口,误配会导致非管理员执行敏感命令 |
+| 14 | 路由策略配置 | 中 | 否 | P2 | HubRoutingPolicyTab | Review/Architecture 路由偏好,误配导致任务分发偏航 |
+
+### 四、GitHub 集成
+
+| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 |
+|---|------|--------|--------|--------|---------|------|
+| 12 | GitHub PR 自动化配置 | 低 | 部分 | P2 | 内置连接器 | Token 配置 + 仓库绑定 |
+
+### 五、运维与恢复
+
+| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 |
+|---|------|--------|--------|--------|---------|------|
+| 15 | 连接器失效恢复 | 中 | 是 | P2 | 各 Adapter + HubConnectorConfigTab | Token 过期/二维码失效后的重连路径,区别于首次接入 |
+
+## 场景详情(P0 + 部分 P1 展开)
+
+### 场景 1: 添加成员(P0,纯内部)
+
+```
+前置: 无
+步骤概要:
+1. [console_action] 打开 Hub → 成员总览
+2. [console_action] 点击"添加成员"
+3. [console_action] Step 1: 选择 Client(Claude/Codex/Antigravity)
+4. [console_action] Step 2: 选择 Provider Profile(从已配置的账号中选)
+5. [branch] 如果没有 Provider Profile → 跳转"配置 API Provider"子流程
+6. [console_action] Step 3: 选择模型
+7. [console_action] 完成创建
+8. [console_action] 编辑成员详情(别名/颜色/路由策略)
+9. [verification] 验证成员可响应(发送测试消息)
+预计时间: 5min
+```
+
+### 场景 4: 飞书对接(P0,跨系统)
+
+```
+前置: 无
+步骤概要:
+1. [external_instruction] 打开飞书开放平台,创建企业自建应用
+ - assets: 2 张截图(创建应用界面 + 机器人能力开关)
+ - link: https://open.feishu.cn/
+2. [external_instruction] 配置权限(im:message + im:message:send_as_bot)
+ - assets: 1 张截图(权限列表)
+3. [collect_input] 复制 App ID + App Secret
+4. [console_action] 打开 Hub → 连接器配置
+5. [console_action] 填入凭证(auto_fill_from 自动填充)
+6. [external_instruction] 在飞书配置事件回调 URL
+ - template_vars: webhook_url
+ - assets: 1 张截图
+7. [verification] 连通性测试(verifierId: feishu-connection-test)
+8. [information] 完成!去飞书给机器人发条消息试试
+预计时间: 10min
+```
+
+### 场景 5: 微信个人号对接(P1,跨系统)
+
+```
+前置: 微信版本 ≥ 8.0.50
+步骤概要:
+1. [information] 前置条件声明:微信版本要求
+ - assets: 1 张截图(版本检查位置)
+2. [external_instruction] 打开微信扫一扫
+ - assets: 1 张截图(微信扫一扫入口)
+3. [console_action] 打开微信对接页面 → 生成二维码
+4. [external_instruction] 用微信扫描屏幕上的二维码
+ - assets: 1 张截图(扫码界面)
+5. [verification] 等待扫码成功(verifierId: wechat-qr-scan)
+6. [information] 扫码成功!现在发一条微信消息试试
+7. [console_action] 引导用户打开左侧出现的微信 DM 会话
+ - observe: { fields: [{ key: wechat_dm_visible }], on_idle: 30s }
+8. [information] 微信对接完成!
+预计时间: 5min
+```
+
+### 场景 6: Telegram 对接(P1,跨系统)
+
+```
+前置: Telegram 账号
+步骤概要:
+1. [external_instruction] 在 Telegram 找到 @BotFather
+ - link: https://t.me/BotFather
+2. [external_instruction] 发送 /newbot,按提示创建 Bot
+ - assets: 2 张截图(创建流程 + Token 获取)
+3. [collect_input] 复制 Bot Token
+4. [console_action] 打开连接器配置 → Telegram
+5. [console_action] 填入 Bot Token
+6. [verification] 连通性测试
+7. [information] 完成!去 Telegram 给 Bot 发条消息
+预计时间: 5min
+```
+
+### 场景 7: 钉钉对接(P1,跨系统)
+
+```
+前置: 钉钉企业管理员权限
+步骤概要:
+1. [external_instruction] 打开钉钉开放平台,创建企业内部应用
+ - link: https://open-dev.dingtalk.com/
+ - assets: 2 张截图
+2. [external_instruction] 启用机器人能力 + 配置 Stream 模式
+ - assets: 1 张截图
+3. [collect_input] 复制 AppKey + AppSecret + RobotCode
+4. [console_action] 打开连接器配置 → 钉钉
+5. [console_action] 填入凭证
+6. [verification] 连通性测试
+7. [information] 完成!在钉钉群里 @机器人试试
+预计时间: 10min
+```
+
+## 资产清单(截图需求汇总)
+
+| 场景 | 预计截图数 | 外部平台 |
+|------|----------|---------|
+| 飞书对接 | 4-5 张 | 飞书开放平台 |
+| 微信对接 | 3-4 张 | 微信 App |
+| Telegram 对接 | 2-3 张 | Telegram App |
+| 钉钉对接 | 3-4 张 | 钉钉开放平台 |
+| 企业微信对接 | 待定 | 企业微信管理后台 |
+
+> 截图在实际编排时按 `guide-authoring` skill Step 5 准备,用橙色圆圈标出关键操作位置。
+
+## 变更记录
+
+- 2026-03-27: 初版 12 场景 (宪宪)
+- 2026-03-27: 补 3 场景 (#13 权限配置 / #14 路由策略 / #15 连接器失效恢复),基于砚砚补漏审计
diff --git a/docs/features/F155-scene-guidance-phase-a-spec.md b/docs/features/F155-scene-guidance-phase-a-spec.md
new file mode 100644
index 000000000..da368ff35
--- /dev/null
+++ b/docs/features/F155-scene-guidance-phase-a-spec.md
@@ -0,0 +1,273 @@
+---
+feature_ids: [F155]
+related_features: [F087, F110, F134, F099]
+topics: [guidance, ux, mcp, frontend, security]
+doc_kind: spec
+created: 2026-03-27
+---
+
+# F155: Scene-Based Bidirectional Guidance Engine
+
+> **Status**: Phase A accepted / frozen — 基础引导引擎已验收冻结,可开 PR 合入 | **Owner**: 布偶猫/宪宪 | **Priority**: P1
+
+## Why
+
+Console 功能日益复杂,但入口简单,用户不知道从哪开始。复杂配置(如飞书对接)涉及跨系统操作,用户需要在多个平台间来回切换,容易迷失。
+
+当前痛点:
+- 用户不知道"添加新成员"需要先配认证
+- 飞书/钉钉等外部系统的权限配置需要反复截图沟通
+- 猫猫无法实时看到用户操作状态,只能靠用户描述和截图诊断问题
+
+> 铲屎官原话:"我们的目标是让我们自己只承载我们真真需要的配置和功能;剩下的通过引导式来承载。猫猫们可以实时观察到当前用户的操作状态和效果,如果失败也就知道哪里有问题。不需要用户自己反复截图来证明和说明自己做的咋样了。"
+
+## What
+
+### 核心架构(v2 — tag-based auto-advance engine)
+
+```
+data-guide-id tags → Flow YAML (guides/flows/) → Runtime API → Guide Engine (Frontend)
+ ↕ Socket.io
+ Cat (状态感知)
+```
+
+**设计原则**(CVO Phase A 反馈收敛):
+- 自动推进:用户与目标元素交互后引导自动前进,无手动 下一步/上一步/跳过
+- HUD 极简:仅显示 tips + progress dots + "退出"
+- 标签驱动:前端元素仅标注 `data-guide-id`,tips 来自 YAML flow 定义
+- 运行时加载:flow 由 `GET /api/guide-flows/:guideId` 运行时获取,非构建时生成
+
+### Phase A: Core Engine + 内部场景验证(✅ 已实现)
+
+**OrchestrationStep schema**(前后端共享):
+```typescript
+interface OrchestrationStep {
+ id: string;
+ target: string; // data-guide-id value
+ tips: string; // 引导文案(来自 YAML)
+ advance: 'click' | 'visible' | 'input' | 'confirm';
+}
+```
+
+**元素标签系统**:页面关键控件加稳定 `data-guide-id`,命名空间式(如 `hub.trigger`、`cats.add-member`),语义而非位置。Target whitelist: `/^[a-zA-Z0-9._-]+$/`。
+
+**Flow YAML**:`guides/flows/*.yaml` 编排场景流程,`guides/registry.yaml` 注册发现。
+
+**Guide Engine(前端)**:
+- 全屏遮罩 + 目标元素区域镂空(呼吸灯动效)+ 四面板 click shield(镂空区可穿透点击)
+- rAF 循环跟踪目标元素位置(rect 比较优化)
+- 自动推进:`useAutoAdvance` hook 监听 click/input/visible/confirm 事件
+- `guide:confirm` CustomEvent 用于确认型步骤(如保存成功后触发)
+- 终态守卫:`setPhase('complete')` 后不可被 rAF 覆写为 `locating`
+- HUD:tips + progress dots + "退出",位置自动计算避免遮挡
+- Error boundary:Guide crash 不影响主应用
+
+**完成回调(frontend → backend)**:
+- 前端 `phase='complete'` 时自动调用 `POST /api/guide-actions/complete`
+- 后端 `guideState: active → completed` + 发 `guide_complete` Socket.io 事件
+- 猫猫收到事件即可感知用户已完成引导
+
+**前端 API 端点**(userId-based auth):
+- `POST /api/guide-actions/start` — offered/awaiting_choice → active
+- `POST /api/guide-actions/cancel` — → cancelled
+- `POST /api/guide-actions/complete` — active → completed
+- `GET /api/guide-flows/:guideId` — 运行时获取 flow 定义
+
+**MCP 工具**(callback auth):
+- `resolve` — 根据用户意图匹配候选流程
+- `start` — 启动引导 session
+- `control` — next/back/skip/exit
+- `update-guide-state` — 通用状态机更新
+
+**CI 验证**:`scripts/gen-guide-catalog.mjs` 校验 v2 schema + target whitelist
+
+**P0 验证场景**:添加新成员(4 步:open-hub → go-to-cats → click-add-member → edit-member-profile)
+
+### Phase B: 平台内场景扩展(F155 scope)
+
+> **Scope 调整(KD-13 + KD-14)**:
+> - Phase B 聚焦平台内已有功能的引导场景扩展,不做跨系统深度集成
+> - 双向可观测(observe/verifier)已拆出为独立 feature 待立项,不再是 F155 的一部分
+> - 外部平台(飞书/微信等)的配置流程按场景单独做配置页签,不纳入 Guide Engine
+
+**场景扩展**:基于已有 Console 功能逐场景补充引导流程,复用 Phase A 骨架(data-guide-id + Flow YAML + advance mode + complete callback)。具体场景所需的额外步骤类型或信息补充,结合场景实际需求决定。
+
+**CI 契约测试**:flow schema + tag 存在性 + 退出路径
+
+**P0 验证场景**:基于已有 Console 功能的高价值场景(如 API Provider 配置、连接器配置等)
+
+### 已拆出的独立方向
+
+| 方向 | 归属 | 说明 |
+|------|------|------|
+| 自动观测 substrate | 独立 feature 待立项 | 不只服务 guide,可被 guide/debug/diagnostics 复用。含 observe.fields、idle 检测、verifier 契约、猫眼指示灯 |
+| 跨系统配置页签 | 按场景单独设计 | 飞书/微信/钉钉等外部平台配置流程,不走 Guide Engine 遮罩引导 |
+
+### 当前进展与阶段判断(2026-04-09)
+
+| 维度 | 当前状态 | 说明 |
+|------|---------|------|
+| 核心引擎 | ✅ 完成 | tag-based runtime、YAML flow、前端遮罩/镂空、auto-advance、exit-only HUD 已跑通 |
+| P0 内部场景 | ✅ 完成 | `add-member` 已收口为 4 步:`hub.trigger → cats.overview → cats.add-member → member-editor.profile(confirm)` |
+| 完成态闭环 | ✅ 完成 | 前端 `complete` → 后端 `guideState=completed` → Socket 通知猫 → 一次性消费 ack |
+| Esc 误退修复 | ✅ 完成 | KD-14:GuideOverlay preventDefault + CatCafeHub guideActive guard |
+| CVO 验收 | ✅ 通过 | 2026-04-09 CVO 手动测试”添加成员”流程,确认链路通畅 |
+| gpt52 review | ✅ 放行 | completion callback 6 轮 + 收尾 2 轮,全部 P1/P2 已修复 |
+| 当前阶段判断 | **Phase A accepted / frozen** | 基础引导引擎已验收冻结,可开 PR 合入 main |
+
+**Phase A 交付物**:
+- 前端引擎:`guideStore.ts` + `useGuideEngine.ts` + `GuideOverlay.tsx`(含 auto-advance)
+- 后端 API:`guide-action-routes.ts`(start/cancel/complete)
+- 路由感知:`route-serial.ts` + `route-parallel.ts`(completionAcked + guideCompletionOwner + catProducedOutput)
+- Prompt 注入:`SystemPromptBuilder.ts`(completed handler)
+- 测试:22 个 API 测试
+- 文档:feature doc + guide-authoring skill + flow YAML + tag manifest
+
+### 触发与发现规范
+
+三层触发机制:
+1. **对话触发(主)**:用户问意图 → 猫查 catalog → 建议引导 → [🐾 带我去做] 卡片 → 启动
+2. **主动发现**:系统检测到未完成配置 → 猫主动建议相关引导
+3. **目录浏览**:Console "场景引导" 入口,按类别列出所有可用流程
+
+### guide-authoring Skill
+
+已创建 `cat-cafe-skills/guide-authoring/SKILL.md`,定义 6 步标准 SOP(v2):
+场景识别 → YAML 编排(v2 auto-advance) → 标签标注 → 注册发现 → CI 契约 → E2E 验证。
+
+### 场景优先级(能力审计结果)
+
+| 优先级 | 场景 | Console Tab | 复杂度 | 跨系统 |
+|--------|------|------------|--------|--------|
+| P0 | 添加成员 | cats → HubCatEditor | 极高 | 否 |
+| ~~P0~~ deferred | 飞书对接 | 独立配置页签(不走 Guide Engine)| 高 | 是 |
+| P1 | 配置 API Provider | provider-profiles | 高 | 否 |
+| P1 | 添加连接器(通用) | connector config | 高 | 是 |
+| P1 | 开启推送通知 | notify | 中 | 否 |
+| P2 | 管理猫猫能力 | capabilities | 中 | 否 |
+| P2 | 治理看板配置 | governance | 中 | 否 |
+
+### 触发与发现(详细设计)
+
+**Guide Registry**(`guides/registry.yaml`):注册所有可用引导,含 keywords + 意图映射。
+**MCP Tool**:`guide_resolve(intent, context)` → 关键词匹配 registry → 返回候选引导列表。
+**Skill Manifest**:猫检测到配置意图("怎么/如何/配置")→ 自动查 registry → 问用户"要我带你走一遍吗?"。
+**主动发现**:后端检测未完成配置状态 → 推送建议到聊天(复用现有 Socket.io 事件管道)。
+
+## Acceptance Criteria
+
+### Phase A(Core Engine)
+- [x] AC-A1: 页面关键控件有稳定 `data-guide-id` 标签(覆盖"添加成员"流程 4 个元素)
+- [x] AC-A2: Guide flow YAML 加载器 + CI schema 验证(v2 schema + target whitelist)
+- [x] AC-A3: Guide Engine 前端组件:遮罩 + 高亮 + 自动推进(v2: 无手动导航,HUD 仅退出)
+- [x] AC-A4: MCP resolve/start/control 工具 + 前端 action routes(start/cancel/complete)
+- [x] AC-A5: "添加成员" 引导流程端到端可运行(含 confirm 步骤 + 保存成功回调)
+- [x] AC-A6: 对话触发:猫建议引导 → InteractiveBlock → 用户确认 → 启动
+- [x] AC-A7: 完成回调:前端 complete → 后端 guideState completed → Socket.io 通知猫猫
+
+### Phase B(平台内场景扩展)
+- [ ] AC-B1: 基于已有 Console 功能扩展 2+ 个引导场景(如 API Provider 配置、连接器配置)
+- [ ] AC-B2: CI 契约测试通过(flow schema + tag + 退出路径)
+
+### 已拆出(不再属于 F155 scope)
+- ~~AC-B1(旧): observe 层~~ → 独立 feature "自动观测 substrate" 待立项
+- ~~AC-B2(旧): MCP guide_observe~~ → 同上
+- ~~AC-B4(旧): 猫眼观测指示灯~~ → 同上
+- ~~AC-B5: 飞书 E2E~~ → KD-13 deferred
+- ~~AC-S1: Sensitive Data Containment~~ → 随独立 observe feature 走
+- ~~AC-S2: Verifier Permission Boundary~~ → 随独立 observe feature 走
+
+### 安全门禁(F155 scope 内保留)
+- [ ] AC-S3: CI Contract Gate — flow schema 合法性 + tag 存在性 + 退出路径
+
+## AC-S3 测试矩阵(F155 scope 内保留)
+
+> AC-S1(Sensitive Data)和 AC-S2(Verifier Boundary)已随 observe substrate 拆出为独立 feature。
+> 原始测试矩阵草案保留在 git 历史中(commit `a6588af` 之前),独立 feature 立项时可参考。
+
+### AC-S3: CI Contract Gate
+
+| Test ID | 层级 | 场景 | 期望结果 | 证据 |
+|---|---|---|---|---|
+| S3-CI1 | CI-Static | Flow schema 校验(step 字段 + advance 类型) | 非法 flow 阻塞合并 | CI 日志 |
+| S3-CI2 | CI-Static | flow target 与 `data-guide-id` manifest 对照 | 缺失/重命名标签阻塞合并 | CI 日志 |
+| S3-CI3 | CI-Static | flow 退出路径校验 | 无退出路径阻塞合并 | CI 日志 |
+| S3-E2E-A1 | CI-E2E | P0 场景回归:添加成员(纯内部) | 主路径可完成,关键状态可回放 | E2E junit XML |
+
+### 质量门禁映射
+
+- PR Gate(必须):S3-CI1~CI3
+- Phase A Gate(必须):S3-E2E-A1
+
+## Dependencies
+
+- **Related**: F087(猫猫训练营 — 类似的引导概念,但面向不同场景)
+- **Related**: F110(训练营愿景引导增强 — 引导 UX 模式可复用)
+- **Related**: F134(飞书群聊 — 飞书对接是 P0 验证场景之一)
+- **Related**: F099(Hub 导航可扩展 — Hub tab/深链基础设施)
+
+## Risk
+
+| 风险 | 缓解 |
+|------|------|
+| 元素标签被 UI 重构意外删除/重命名 | CI 契约测试(AC-S3)阻塞合并 |
+| 跨系统流程用户中途放弃导致状态不一致 | sessionStorage 持久化 + 猫猫感知 idle 超时 |
+| collect_input 敏感值泄露 | AC-S1 封存规则 + 服务端 TTL |
+| 流程文档与页面演进脱节 | CI gate 每次构建校验 tag manifest |
+| Guide Engine 性能影响正常操作 | 遮罩层 z-index 隔离 + 不影响非引导区域交互 |
+
+## Open Questions
+
+| # | 问题 | 状态 |
+|---|------|------|
+| OQ-1 | Guide Engine 是否需要支持流程嵌套(一个 flow 调用另一个 flow 作为子步骤)? | ⬜ 未定(建议 Phase B 后评估) |
+| OQ-2 | 主动发现的触发条件如何定义?由前端检测还是后端推送? | ⬜ 未定 |
+| OQ-3 | guide-authoring skill 是否需要自动从录屏生成初始 flow YAML? | ⬜ 未定 |
+
+## Key Decisions
+
+| # | 决策 | 理由 | 日期 |
+|---|------|------|------|
+| KD-1 | 选择"标签 + YAML 编排 + Guide Runtime"方案,否决硬编码和纯动态方案 | 可测、可审计、可版本化;新场景不改代码 | 2026-03-27 |
+| KD-2 | 双向可观测:猫猫实时感知用户操作状态 | 免截图诊断;猫猫能主动介入卡点 | 2026-03-27 |
+| KD-3 | sensitive 值刷新后不恢复,强制重填 | 安全优先于便利 | 2026-03-27 |
+| KD-4 | 有副作用的 verification 按 verifier 配置 confirm: required/auto | sideEffect=true 必须二次确认,CI 校验规则 | 2026-03-27 |
+| KD-5 | P0 skip_if 限声明式比较(eq/in/exists/gt/lt),禁止表达式 | 沙箱成本高,声明式可满足 P0 需求 | 2026-03-27 |
+| KD-6 | observe.fields 对 sensitive 字段只上报 {filled, valid} | 防止侧信道泄漏长度/前缀 | 2026-03-27 |
+| KD-7 | 迭代策略:核心引擎先完整 → P0(1内部+1外部)验收 → 再逐场景补全 | 不一次性实现所有场景;编排文件按需补充 | 2026-03-27 |
+| KD-8 | external_instruction 支持富内容(多图 + 链接 + 前置条件 + 版本要求) | 胶囊 HUD 不够,外部步骤需要完整的操作指引卡片 | 2026-03-27 |
+| KD-9 | v2 重构:自动推进取代手动导航,HUD 仅保留"退出" | CVO Phase A 反馈:手动导航降低体验,用户操作即推进 | 2026-03-30 |
+| KD-10 | v2 步骤类型收敛为 4 种 advance mode(click/visible/input/confirm) | 简化 Phase A 范围,6 种步骤类型推迟到 Phase B 按需扩展 | 2026-03-30 |
+| KD-11 | Flow YAML 运行时加载(API),不在构建时生成 TS | 解耦部署:改 flow 不需要重新构建前端 | 2026-03-30 |
+| KD-12 | 完成回调作为基础能力:前端 complete → 后端状态 + Socket 通知 | CVO 明确要求:完整流程闭环是基础能力,不是后续补充 | 2026-04-03 |
+| KD-13 | Phase B 聚焦平台内引导,外部平台配置改为独立页签(不走 Guide Engine) | CVO:跨系统对接方式可能变化(扫码等),引导引擎聚焦已有功能;外部流程按场景单独做页签 | 2026-04-06 |
+| KD-14 | 禁用引导模式下全局 Esc 退出,仅保留显式退出按钮 | CVO 手测反馈:误触 Esc 导致引导意外退出,体验差 | 2026-04-09 |
+| KD-15 | 双向可观测拆出为独立 feature,不再是 F155 Phase B | CVO + gpt52 共识:observe substrate 应更大——不只服务 guide,可被 debug/diagnostics 复用 | 2026-04-09 |
+
+## Timeline
+
+| 日期 | 事件 |
+|------|------|
+| 2026-03-27 | 三猫讨论收敛 + 立项 |
+| 2026-03-30 | v2 tag-based auto-advance engine 跑通,HUD 收敛为 exit-only,flow 改为运行时加载 |
+| 2026-03-31 | P0 场景 add-member 4 步端到端验证通过 |
+| 2026-04-01 | `add-member` 第 4 步收敛为 `confirm` 型步骤,保存成功后才允许完成 |
+| 2026-04-03 | guide completion callback 打通:前端 complete → 后端 `guideState=completed` → Socket `guide_complete` |
+| 2026-04-06 | CVO 方向校准:Phase B 聚焦平台内引导,跨系统配置改为独立页签(KD-13) |
+| 2026-04-09 | CVO 验收 Phase A 通过;Esc 误退修复(KD-14);observe 拆出独立 feature(KD-15);Phase A accepted/frozen |
+
+## Review Gate
+
+- Phase A: 砚砚(gpt52) 负责安全边界 + 可测性 review
+- Phase B: 砚砚(gpt52) 安全 review + 烁烁(gemini25) 视觉 review
+
+## Links
+
+| 类型 | 路径 | 说明 |
+|------|------|------|
+| **Discussion** | `docs/discussions/2026-03-27-F150-guidance-engine-convergence.md` | 三猫讨论收敛纪要 |
+| **Scene Catalog** | `docs/features/F155-scene-catalog.md` | 全量引导场景清单(12 场景,含步骤概要) |
+| **Skill** | `cat-cafe-skills/guide-authoring/SKILL.md` | 引导流程设计 SOP |
+| **Feature** | `docs/features/F087-bootcamp.md` | 类似引导概念(训练营) |
+| **Feature** | `docs/features/F134-feishu-group-chat.md` | 飞书对接(P0 验证场景) |
+| **Feature** | `docs/features/F137-weixin-personal-gateway.md` | 微信对接(P1 验证场景) |
diff --git a/docs/features/index.json b/docs/features/index.json
index f00878d94..7bac303d5 100644
--- a/docs/features/index.json
+++ b/docs/features/index.json
@@ -942,12 +942,30 @@
"status": "in-progress | **Owner**: Ragdoll | **Priority**: P2",
"file": "F154-cat-routing-personalization.md"
},
+ {
+ "id": "F155",
+ "name": "Add-Member Internal Guide UI Spec (Phase A)",
+ "status": "unknown",
+ "file": "F155-add-member-guide-ui-spec.md"
+ },
+ {
+ "id": "F155",
+ "name": "引导场景清单",
+ "status": "unknown",
+ "file": "F155-scene-catalog.md"
+ },
{
"id": "F155",
"name": "Scene-Based Guidance Engine — 场景式交互引导",
"status": "needs-discussion | **Source**: Community (mindfn) | **Priority**: TBD",
"file": "F155-scene-guidance-engine.md"
},
+ {
+ "id": "F155",
+ "name": "Scene-Based Bidirectional Guidance Engine",
+ "status": "Phase A accepted / frozen — 基础引导引擎已验收冻结,可开 PR 合入 | **Owner**: 布偶猫/宪宪 | **Priority**: P1",
+ "file": "F155-scene-guidance-phase-a-spec.md"
+ },
{
"id": "F156",
"name": "Security Hardening — 实时通道 + 本机信任边界加固",
diff --git a/guides/flows/add-member.yaml b/guides/flows/add-member.yaml
new file mode 100644
index 000000000..480df9c00
--- /dev/null
+++ b/guides/flows/add-member.yaml
@@ -0,0 +1,28 @@
+# F150 Guide Flow: 添加成员
+# P0 验证场景 — 引导到添加成员入口
+# Schema: OrchestrationFlow v2 (tag-based engine)
+
+id: add-member
+name: 添加成员
+description: 引导你完成新成员创建并补充成员信息
+
+steps:
+ - id: open-hub
+ target: hub.trigger
+ tips: 点击这里打开 Hub 控制台
+ advance: click
+
+ - id: go-to-cats
+ target: cats.overview
+ tips: 进入成员管理页面
+ advance: click
+
+ - id: click-add-member
+ target: cats.add-member
+ tips: 点击这里开始添加新成员
+ advance: click
+
+ - id: edit-member-profile
+ target: member-editor.profile
+ tips: 补充成员信息并点击保存;保存成功后会自动完成本次引导
+ advance: confirm
diff --git a/guides/registry.yaml b/guides/registry.yaml
new file mode 100644
index 000000000..cd0ee078a
--- /dev/null
+++ b/guides/registry.yaml
@@ -0,0 +1,23 @@
+# F150 Guide Registry
+# Maps guide IDs to flow files, keywords, and discovery metadata.
+# MCP resolve tool matches user intent against keywords to suggest guides.
+
+guides:
+ - id: add-member
+ name: 添加成员
+ description: 引导你完成新成员的创建和配置
+ flow_file: flows/add-member.yaml
+ keywords:
+ - 添加成员
+ - 加成员
+ - 新成员
+ - 加猫
+ - 新猫
+ - 新建成员
+ - add member
+ - add cat
+ - create member
+ category: member-config
+ priority: P0
+ cross_system: false
+ estimated_time: 3min
diff --git a/package.json b/package.json
index bfe33e0c9..1b9beccd9 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,8 @@
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"lint": "pnpm -r run lint",
- "check": "pnpm biome check . --diagnostic-level=error && pnpm check:features && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation",
+ "check": "pnpm biome check . --diagnostic-level=error && pnpm check:features && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation && pnpm check:guides",
+ "check:guides": "node scripts/gen-guide-catalog.mjs",
"check:fix": "pnpm biome check --write .",
"test": "pnpm -r --if-present run test",
"test:api:redis": "pnpm --filter @cat-cafe/api run test:redis",
diff --git a/packages/api/.claude/skills b/packages/api/.claude/skills
new file mode 120000
index 000000000..2c4b4dae4
--- /dev/null
+++ b/packages/api/.claude/skills
@@ -0,0 +1 @@
+../../../cat-cafe-skills
\ No newline at end of file
diff --git a/packages/api/AGENTS.md b/packages/api/AGENTS.md
new file mode 100644
index 000000000..23f41615d
--- /dev/null
+++ b/packages/api/AGENTS.md
@@ -0,0 +1,30 @@
+
+> Pack version: 1.3.0 | Provider: codex
+
+## Cat Cafe Governance Rules (Auto-managed)
+
+### Hard Constraints (immutable)
+- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime.
+- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test.
+- **No self-review**: The same individual cannot review their own code. Cross-family review preferred.
+- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint.
+
+### Collaboration Standards
+- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action
+- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete.
+- Review flow: quality-gate → request-review → receive-review → merge-gate
+- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step
+- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract
+
+### Quality Discipline (overrides "try simplest approach first")
+- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix
+- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first"
+- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green
+
+### Knowledge Engineering
+- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created)
+- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/
+- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold)
+- Feature lifecycle: kickoff → discussion → implementation → review → completion
+- SOP: See docs/SOP.md for the 6-step workflow
+
diff --git a/packages/api/BACKLOG.md b/packages/api/BACKLOG.md
new file mode 100644
index 000000000..ca1b5d488
--- /dev/null
+++ b/packages/api/BACKLOG.md
@@ -0,0 +1,13 @@
+---
+topics: [backlog]
+doc_kind: note
+created: 2026-03-31
+---
+
+# Feature Roadmap
+
+> **Rules**: Only active Features (idea/spec/in-progress/review). Move to done after completion.
+> Details in `docs/features/Fxxx-*.md`.
+
+| ID | Name | Status | Owner | Link |
+|----|------|--------|-------|------|
diff --git a/packages/api/CLAUDE.md b/packages/api/CLAUDE.md
new file mode 100644
index 000000000..31316c6e7
--- /dev/null
+++ b/packages/api/CLAUDE.md
@@ -0,0 +1,30 @@
+
+> Pack version: 1.3.0 | Provider: claude
+
+## Cat Cafe Governance Rules (Auto-managed)
+
+### Hard Constraints (immutable)
+- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime.
+- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test.
+- **No self-review**: The same individual cannot review their own code. Cross-family review preferred.
+- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint.
+
+### Collaboration Standards
+- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action
+- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete.
+- Review flow: quality-gate → request-review → receive-review → merge-gate
+- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step
+- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract
+
+### Quality Discipline (overrides "try simplest approach first")
+- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix
+- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first"
+- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green
+
+### Knowledge Engineering
+- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created)
+- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/
+- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold)
+- Feature lifecycle: kickoff → discussion → implementation → review → completion
+- SOP: See docs/SOP.md for the 6-step workflow
+
diff --git a/packages/api/GEMINI.md b/packages/api/GEMINI.md
new file mode 100644
index 000000000..b058371be
--- /dev/null
+++ b/packages/api/GEMINI.md
@@ -0,0 +1,30 @@
+
+> Pack version: 1.3.0 | Provider: gemini
+
+## Cat Cafe Governance Rules (Auto-managed)
+
+### Hard Constraints (immutable)
+- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime.
+- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test.
+- **No self-review**: The same individual cannot review their own code. Cross-family review preferred.
+- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint.
+
+### Collaboration Standards
+- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action
+- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete.
+- Review flow: quality-gate → request-review → receive-review → merge-gate
+- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step
+- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract
+
+### Quality Discipline (overrides "try simplest approach first")
+- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix
+- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first"
+- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green
+
+### Knowledge Engineering
+- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created)
+- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/
+- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold)
+- Feature lifecycle: kickoff → discussion → implementation → review → completion
+- SOP: See docs/SOP.md for the 6-step workflow
+
diff --git a/packages/api/docs/SOP.md b/packages/api/docs/SOP.md
new file mode 100644
index 000000000..b11204f76
--- /dev/null
+++ b/packages/api/docs/SOP.md
@@ -0,0 +1,24 @@
+---
+topics: [sop, workflow]
+doc_kind: note
+created: 2026-03-31
+---
+
+# Standard Operating Procedure
+
+## Workflow (6 steps)
+
+| Step | What | Skill |
+|------|------|-------|
+| 1 | Create worktree | `worktree` |
+| 2 | Self-check (spec compliance) | `quality-gate` |
+| 3 | Peer review | `request-review` / `receive-review` |
+| 4 | Merge gate | `merge-gate` |
+| 5 | PR + cloud review | (merge-gate handles) |
+| 6 | Merge + cleanup | (SOP steps) |
+
+## Code Quality
+
+- Biome: `pnpm check` / `pnpm check:fix`
+- Types: `pnpm lint`
+- File limits: 200 lines warn / 350 hard cap
diff --git a/packages/api/docs/decisions/.gitkeep b/packages/api/docs/decisions/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/api/docs/discussions/.gitkeep b/packages/api/docs/discussions/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/api/docs/features/.gitkeep b/packages/api/docs/features/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/api/docs/features/TEMPLATE.md b/packages/api/docs/features/TEMPLATE.md
new file mode 100644
index 000000000..f51272c38
--- /dev/null
+++ b/packages/api/docs/features/TEMPLATE.md
@@ -0,0 +1,20 @@
+---
+feature_ids: [Fxxx]
+related_features: []
+topics: []
+doc_kind: spec
+created: 2026-03-31
+---
+
+# Fxxx: Feature Name
+
+> Status: spec | Owner: TBD
+
+## Why
+## What
+## Acceptance Criteria
+- [ ] AC-1: ...
+
+## Dependencies
+## Risk
+## Open Questions
diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
index 854d4060c..49a9b81b3 100644
--- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
+++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
@@ -995,10 +995,11 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
// Prepend staticIdentity to prompt when injection is needed
// F070-P2: missionPrefix (dispatch context) is prepended for external projects
const promptWithMission = missionPrefix ? `${missionPrefix}\n\n${prompt}` : prompt;
+
const effectivePrompt =
injectSystemPrompt && params.systemPrompt
? `${params.systemPrompt}\n\n---\n\n${promptWithMission}`
- : promptWithMission;
+ : `${promptWithMission}`;
// F089 Phase 2+3: Create tmux spawn override for agent-in-pane execution
let spawnCliOverride: AgentServiceOptions['spawnCliOverride'];
diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts
index 0c324cca5..e9f915061 100644
--- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts
+++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts
@@ -9,6 +9,7 @@ import { getCatContextBudget } from '../../../../../config/cat-budgets.js';
import { getConfigSessionStrategy, isSessionChainEnabled } from '../../../../../config/cat-config-loader.js';
import { createModuleLogger } from '../../../../../infrastructure/logger.js';
import { estimateTokens } from '../../../../../utils/token-counter.js';
+import { canAccessGuideState, hasHiddenForeignNonTerminalGuideState } from '../../../../guides/guide-state-access.js';
import { assembleContext } from '../../context/ContextAssembler.js';
import {
buildInvocationContext,
@@ -45,6 +46,34 @@ import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_S
const log = createModuleLogger('route-parallel');
+function shouldHandleCompletedGuide(
+ guideCompletionOwner: string | undefined,
+ targetCatIds: ReadonlySet,
+ fallbackCatId: string | undefined,
+ catId: string,
+): boolean {
+ if (!guideCompletionOwner) return true;
+ if (guideCompletionOwner === catId) return true;
+ if (!targetCatIds.has(guideCompletionOwner)) return fallbackCatId === catId;
+ return false;
+}
+
+function shouldHandleOfferedGuide(
+ guideOfferOwner: string | undefined,
+ targetCatIds: ReadonlySet,
+ fallbackCatId: string | undefined,
+ catId: string,
+ hasUserSelection: boolean,
+ allowOwnerMissingFallback = false,
+): boolean {
+ if (!guideOfferOwner) return true;
+ if (guideOfferOwner === catId) return true;
+ if ((hasUserSelection || allowOwnerMissingFallback) && !targetCatIds.has(guideOfferOwner)) {
+ return fallbackCatId === catId;
+ }
+ return false;
+}
+
export async function* routeParallel(
deps: RouteStrategyDeps,
targetCats: CatId[],
@@ -89,12 +118,73 @@ export async function* routeParallel(
let voiceMode: boolean | undefined;
// F087: Bootcamp state for CVO onboarding
let bootcampState: InvocationContext['bootcampState'];
+ // F155: Guide candidate from keyword matching against raw user message
+ let guideCandidate: InvocationContext['guideCandidate'];
+ /** catId that owns an offered guide prompt, to avoid duplicate offered→offered writes. */
+ let guideOfferOwner: string | undefined;
+ /** catId that should receive offered-guide selection when owner is absent. */
+ let guideOfferSelectionFallbackCatId: string | undefined;
+ /** catId that should receive the one-shot completion notice (offeredBy). */
+ let guideCompletionOwner: string | undefined;
+ const targetCatIds = new Set(targetCats);
+ let guideCompletionFallbackCatId: string | undefined;
+ let hiddenForeignNonTerminalGuideState = false;
if (deps.invocationDeps.threadStore) {
try {
const thread = await deps.invocationDeps.threadStore.get(threadId);
routingPolicy = thread?.routingPolicy;
voiceMode = thread?.voiceMode;
bootcampState = thread?.bootcampState;
+ const threadGuideState = thread?.guideState;
+ hiddenForeignNonTerminalGuideState = hasHiddenForeignNonTerminalGuideState(thread, threadGuideState, userId);
+ const guideState = canAccessGuideState(thread, threadGuideState, userId) ? threadGuideState : undefined;
+ // F155: Read existing guide state from thread (authority source)
+ if (guideState) {
+ const gs = guideState;
+ // cancelled: fully terminal, skip
+ // completed: one-shot inject if not yet acked, then mark acked
+ const justCompleted = gs.status === 'completed' && !gs.completionAcked;
+ const shouldInject = (gs.status !== 'completed' && gs.status !== 'cancelled') || justCompleted;
+ if (shouldInject) {
+ let name = gs.guideId;
+ let estimatedTime = '';
+ try {
+ const { getRegistryEntries } = await import('../../../../guides/guide-registry-loader.js');
+ const entry = getRegistryEntries().find((e) => e.id === gs.guideId);
+ if (entry) {
+ name = entry.name;
+ estimatedTime = entry.estimated_time;
+ }
+ } catch {
+ /* best-effort */
+ }
+ const selectionMatch = message.match(/^引导流程:(.+)$/);
+ guideCandidate = {
+ id: gs.guideId,
+ name,
+ estimatedTime,
+ status: gs.status as 'offered' | 'awaiting_choice' | 'active' | 'completed',
+ ...(gs.status === 'offered' ? { isNewOffer: false } : {}),
+ ...(selectionMatch ? { userSelection: selectionMatch[1] } : {}),
+ };
+ if (gs.status === 'offered' || gs.status === 'awaiting_choice') {
+ guideOfferOwner = gs.offeredBy;
+ if (
+ (selectionMatch || gs.status === 'awaiting_choice') &&
+ gs.offeredBy &&
+ !targetCatIds.has(gs.offeredBy)
+ ) {
+ guideOfferSelectionFallbackCatId = targetCats[0];
+ }
+ }
+ if (justCompleted) {
+ guideCompletionOwner = gs.offeredBy;
+ if (gs.offeredBy && !targetCatIds.has(gs.offeredBy)) {
+ guideCompletionFallbackCatId = targetCats[0];
+ }
+ }
+ }
+ }
// F073 P4: Read workflow-sop if thread is linked to a backlog item
if (thread?.backlogItemId && deps.invocationDeps.workflowSopStore) {
try {
@@ -121,6 +211,27 @@ export async function* routeParallel(
const catToolNames = new Map();
const catCoverageMap = new Map();
+ // F155: Match raw user message against guide registry (only if no existing guide state)
+ if (!guideCandidate && !hiddenForeignNonTerminalGuideState) {
+ try {
+ const { resolveGuideForIntent } = await import('../../../../guides/guide-registry-loader.js');
+ const guideMatches = resolveGuideForIntent(message);
+ if (guideMatches.length > 0) {
+ const top = guideMatches[0];
+ guideCandidate = {
+ id: top.id,
+ name: top.name,
+ estimatedTime: top.estimatedTime,
+ status: 'offered',
+ isNewOffer: true,
+ };
+ guideOfferOwner = targetCats[0];
+ }
+ } catch {
+ /* best-effort: guide matching failure does not block invocation */
+ }
+ }
+
const streams = await Promise.all(
targetCats.map(async (catId) => {
const catConfig: CatConfig | undefined =
@@ -175,6 +286,21 @@ export async function* routeParallel(
...(activeSignals ? { activeSignals } : {}),
...(voiceMode ? { voiceMode } : {}),
...(bootcampState ? { bootcampState, threadId } : {}),
+ ...(guideCandidate &&
+ (guideCandidate.status === 'completed'
+ ? shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId)
+ : guideCandidate.status === 'offered' || guideCandidate.status === 'awaiting_choice'
+ ? shouldHandleOfferedGuide(
+ guideOfferOwner,
+ targetCatIds,
+ guideOfferSelectionFallbackCatId,
+ catId,
+ Boolean(guideCandidate.userSelection),
+ guideCandidate.status === 'awaiting_choice',
+ )
+ : true)
+ ? { guideCandidate, threadId }
+ : {}),
});
const targetContentBlocks = routeContentBlocksForCat(catId, contentBlocks);
@@ -561,8 +687,10 @@ export async function* routeParallel(
// recreating an orphan Redis hash key via HSET.
catInvocationId.delete(msg.catId);
const bufferedBlocks = getRichBlockBuffer().consume(threadId, msg.catId, ownInvId);
+ let catProducedOutput = false;
const text = catText.get(msg.catId);
if (text) {
+ catProducedOutput = true;
const meta = catMeta.get(msg.catId);
const sanitized = sanitizeInjectedContent(text);
// F22: Extract cc_rich blocks from text + merge with buffered
@@ -729,22 +857,27 @@ export async function* routeParallel(
const sawUserFacingSystemInfo = catSawUserFacingSystemInfo.get(msg.catId) === true;
const shouldPersistNoTextMessage =
hasRichBlocks || (catTools?.length ?? 0) > 0 || Boolean(thinking?.trim().length ?? 0);
+ const shouldEmitSilentCompletion = (catTools?.length ?? 0) > 0 && !hasRichBlocks && !sawUserFacingSystemInfo;
// Diagnostic: if cat ran tools but produced no text, emit a system_info so the
// user sees *something* instead of a silent vanish (bugfix: silent-exit P1).
- if (catTools && catTools.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo) {
+ if (shouldEmitSilentCompletion) {
yield {
type: 'system_info' as AgentMessageType,
catId: msg.catId,
content: JSON.stringify({
type: 'silent_completion',
detail: `${msg.catId} completed with tool calls but no text response.`,
- toolCount: catTools.length,
+ toolCount: catTools?.length ?? 0,
}),
timestamp: Date.now(),
} as AgentMessage;
}
+ if (shouldPersistNoTextMessage || sawUserFacingSystemInfo || shouldEmitSilentCompletion) {
+ catProducedOutput = true;
+ }
+
if (shouldPersistNoTextMessage) {
try {
await deps.messageStore.append({
@@ -905,6 +1038,37 @@ export async function* routeParallel(
}
}
+ // F155: Ack guide completion only after cat produced visible output.
+ // Skip ack on error-only turns so next turn retries delivery.
+ if (
+ catProducedOutput &&
+ guideCandidate?.status === 'completed' &&
+ shouldHandleCompletedGuide(
+ guideCompletionOwner,
+ targetCatIds,
+ guideCompletionFallbackCatId,
+ msg.catId as string,
+ ) &&
+ deps.invocationDeps.threadStore
+ ) {
+ try {
+ const thread = await deps.invocationDeps.threadStore.get(threadId);
+ const gs = thread?.guideState;
+ if (
+ thread &&
+ gs &&
+ canAccessGuideState(thread, gs, userId) &&
+ gs.guideId === guideCandidate.id &&
+ gs.status === 'completed' &&
+ !gs.completionAcked
+ ) {
+ await deps.invocationDeps.threadStore.updateGuideState(threadId, { ...gs, completionAcked: true });
+ }
+ } catch {
+ /* best-effort: ack failure means next turn re-injects, which is acceptable */
+ }
+ }
+
const isFinal = completedCount === targetCats.length;
// F5: When all parallel cats are done, emit follow-up hints for A2A mentions
diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
index 6ead7533e..b512e7f23 100644
--- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
+++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
@@ -18,6 +18,7 @@ import { getCatVoice } from '../../../../../config/cat-voices.js';
import { createModuleLogger } from '../../../../../infrastructure/logger.js';
import { detectUserMention } from '../../../../../routes/user-mention.js';
import { estimateTokens } from '../../../../../utils/token-counter.js';
+import { canAccessGuideState, hasHiddenForeignNonTerminalGuideState } from '../../../../guides/guide-state-access.js';
import { assembleContext } from '../../context/ContextAssembler.js';
import {
buildInvocationContext,
@@ -56,6 +57,34 @@ import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_S
const log = createModuleLogger('route-serial');
+function shouldHandleCompletedGuide(
+ guideCompletionOwner: string | undefined,
+ targetCatIds: ReadonlySet,
+ fallbackCatId: string | undefined,
+ catId: string,
+): boolean {
+ if (!guideCompletionOwner) return true;
+ if (guideCompletionOwner === catId) return true;
+ if (!targetCatIds.has(guideCompletionOwner)) return fallbackCatId === catId;
+ return false;
+}
+
+function shouldHandleOfferedGuide(
+ guideOfferOwner: string | undefined,
+ targetCatIds: ReadonlySet,
+ fallbackCatId: string | undefined,
+ catId: string,
+ hasUserSelection: boolean,
+ allowOwnerMissingFallback = false,
+): boolean {
+ if (!guideOfferOwner) return true;
+ if (guideOfferOwner === catId) return true;
+ if ((hasUserSelection || allowOwnerMissingFallback) && !targetCatIds.has(guideOfferOwner)) {
+ return fallbackCatId === catId;
+ }
+ return false;
+}
+
export async function* routeSerial(
deps: RouteStrategyDeps,
targetCats: CatId[],
@@ -113,12 +142,75 @@ export async function* routeSerial(
let voiceMode: boolean | undefined;
// F087: Bootcamp state for CVO onboarding
let bootcampState: InvocationContext['bootcampState'];
+ // F155: Guide candidate from keyword matching against raw user message
+ let guideCandidate: InvocationContext['guideCandidate'];
+ /** catId that owns an offered guide prompt, to avoid duplicate offered→offered writes. */
+ let guideOfferOwner: string | undefined;
+ /** catId that should receive offered-guide selection when owner is absent. */
+ let guideOfferSelectionFallbackCatId: string | undefined;
+ /** catId that should receive the one-shot completion notice (offeredBy). */
+ let guideCompletionOwner: string | undefined;
+ let guideCompletionFallbackCatId: string | undefined;
+ let hiddenForeignNonTerminalGuideState = false;
+ const targetCatIds = new Set(targetCats);
if (deps.invocationDeps.threadStore) {
try {
const thread = await deps.invocationDeps.threadStore.get(threadId);
routingPolicy = thread?.routingPolicy;
voiceMode = thread?.voiceMode;
bootcampState = thread?.bootcampState;
+ const threadGuideState = thread?.guideState;
+ hiddenForeignNonTerminalGuideState = hasHiddenForeignNonTerminalGuideState(thread, threadGuideState, userId);
+ const guideState = canAccessGuideState(thread, threadGuideState, userId) ? threadGuideState : undefined;
+ // F155: Read existing guide state from thread (authority source)
+ if (guideState) {
+ const gs = guideState;
+ // cancelled: fully terminal, skip
+ // completed: one-shot inject if not yet acked, then mark acked
+ const justCompleted = gs.status === 'completed' && !gs.completionAcked;
+ const shouldInject = (gs.status !== 'completed' && gs.status !== 'cancelled') || justCompleted;
+ if (shouldInject) {
+ // Back-fill display metadata from registry so prompt gets real name/time
+ let name = gs.guideId;
+ let estimatedTime = '';
+ try {
+ const { getRegistryEntries } = await import('../../../../guides/guide-registry-loader.js');
+ const entry = getRegistryEntries().find((e) => e.id === gs.guideId);
+ if (entry) {
+ name = entry.name;
+ estimatedTime = entry.estimated_time;
+ }
+ } catch {
+ /* best-effort */
+ }
+ // Detect selection response: "引导流程:{label}"
+ const selectionMatch = message.match(/^引导流程:(.+)$/);
+ guideCandidate = {
+ id: gs.guideId,
+ name,
+ estimatedTime,
+ status: gs.status as 'offered' | 'awaiting_choice' | 'active' | 'completed',
+ ...(gs.status === 'offered' ? { isNewOffer: false } : {}),
+ ...(selectionMatch ? { userSelection: selectionMatch[1] } : {}),
+ };
+ if (gs.status === 'offered' || gs.status === 'awaiting_choice') {
+ guideOfferOwner = gs.offeredBy;
+ if (
+ (selectionMatch || gs.status === 'awaiting_choice') &&
+ gs.offeredBy &&
+ !targetCatIds.has(gs.offeredBy)
+ ) {
+ guideOfferSelectionFallbackCatId = targetCats[0];
+ }
+ }
+ if (justCompleted) {
+ guideCompletionOwner = gs.offeredBy;
+ if (gs.offeredBy && !targetCatIds.has(gs.offeredBy)) {
+ guideCompletionFallbackCatId = targetCats[0];
+ }
+ }
+ }
+ }
// F073 P4: Read workflow-sop if thread is linked to a backlog item
if (thread?.backlogItemId && deps.invocationDeps.workflowSopStore) {
try {
@@ -139,6 +231,31 @@ export async function* routeSerial(
}
}
+ // F155: Match raw user message against guide registry (only if no existing guide state)
+ if (!guideCandidate && !hiddenForeignNonTerminalGuideState) {
+ try {
+ const { resolveGuideForIntent } = await import('../../../../guides/guide-registry-loader.js');
+ const guideMatches = resolveGuideForIntent(message);
+ if (guideMatches.length > 0) {
+ const top = guideMatches[0];
+ guideCandidate = {
+ id: top.id,
+ name: top.name,
+ estimatedTime: top.estimatedTime,
+ status: 'offered',
+ isNewOffer: true,
+ };
+ guideOfferOwner = targetCats[0];
+ log.info(
+ { guideId: top.id, guideName: top.name, score: top.score },
+ '[F155] guide candidate matched at routing layer',
+ );
+ }
+ } catch {
+ /* best-effort: guide matching failure does not block invocation */
+ }
+ }
+
try {
while (index < worklist.length) {
if (signal?.aborted) break;
@@ -230,6 +347,21 @@ export async function* routeSerial(
...(activeSignals ? { activeSignals } : {}),
...(voiceMode ? { voiceMode } : {}),
...(bootcampState ? { bootcampState, threadId } : {}),
+ ...(guideCandidate &&
+ (guideCandidate.status === 'completed'
+ ? shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId)
+ : guideCandidate.status === 'offered' || guideCandidate.status === 'awaiting_choice'
+ ? shouldHandleOfferedGuide(
+ guideOfferOwner,
+ targetCatIds,
+ guideOfferSelectionFallbackCatId,
+ catId,
+ Boolean(guideCandidate.userSelection),
+ guideCandidate.status === 'awaiting_choice',
+ )
+ : true)
+ ? { guideCandidate, threadId }
+ : {}),
});
// F24 Phase E: Bootstrap context for Session #2+
@@ -384,6 +516,8 @@ export async function* routeSerial(
let firstMetadata: MessageMetadata | undefined;
let doneMsg: AgentMessage | undefined;
let hadError = false;
+ /** F155: tracks whether cat produced user-visible output (for guide completion ack). */
+ let catProducedOutput = false;
let sawUserFacingSystemInfo = false;
// #267: track errors that happened BEFORE abort — only these are real provider failures
let hadProviderError = false;
@@ -633,6 +767,7 @@ export async function* routeSerial(
let mentionsUser = false;
if (textContent) {
+ catProducedOutput = true;
const sanitized = sanitizeInjectedContent(textContent);
// F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats)
@@ -961,6 +1096,7 @@ export async function* routeSerial(
const hasRichBlocks = noTextBlocks.length > 0;
const shouldPersistNoTextMessage =
hasRichBlocks || collectedToolEvents.length > 0 || Boolean(thinkingContent?.trim().length > 0);
+ const shouldEmitSilentCompletion = collectedToolEvents.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo;
log.debug(
{
@@ -976,7 +1112,7 @@ export async function* routeSerial(
);
// Diagnostic: if cat ran tools but produced no text, emit a system_info so the
// user sees *something* instead of a silent vanish (bugfix: silent-exit P1).
- if (collectedToolEvents.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo) {
+ if (shouldEmitSilentCompletion) {
yield {
type: 'system_info' as AgentMessageType,
catId,
@@ -988,6 +1124,9 @@ export async function* routeSerial(
timestamp: Date.now(),
} as AgentMessage;
}
+ if (shouldPersistNoTextMessage || sawUserFacingSystemInfo || shouldEmitSilentCompletion) {
+ catProducedOutput = true;
+ }
if (shouldPersistNoTextMessage) {
try {
@@ -1175,6 +1314,32 @@ export async function* routeSerial(
});
}
+ // F155: Ack guide completion only after cat produced visible output.
+ // Skip ack on error-only turns so next turn retries delivery.
+ if (
+ catProducedOutput &&
+ guideCandidate?.status === 'completed' &&
+ shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId) &&
+ deps.invocationDeps.threadStore
+ ) {
+ try {
+ const thread = await deps.invocationDeps.threadStore.get(threadId);
+ const gs = thread?.guideState;
+ if (
+ thread &&
+ gs &&
+ canAccessGuideState(thread, gs, userId) &&
+ gs.guideId === guideCandidate.id &&
+ gs.status === 'completed' &&
+ !gs.completionAcked
+ ) {
+ await deps.invocationDeps.threadStore.updateGuideState(threadId, { ...gs, completionAcked: true });
+ }
+ } catch {
+ /* best-effort: ack failure means next turn re-injects, which is acceptable */
+ }
+ }
+
// Yield buffered done with correct isFinal (evaluated AFTER worklist may have grown)
// MUST always reach here regardless of append success (缅因猫 review P1-2)
if (doneMsg) {
diff --git a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
index 1c43b40fa..dea7cd4e4 100644
--- a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
+++ b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
@@ -102,6 +102,20 @@ export interface InvocationContext {
* When present, cats inject bootcamp-guide behavior per phase.
*/
bootcampState?: BootcampStateV1;
+ /**
+ * F155: Matched guide candidate from routing-layer keyword match.
+ * When present, cats load guide-interaction skill and offer the guide.
+ */
+ guideCandidate?: {
+ id: string;
+ name: string;
+ estimatedTime: string;
+ status: 'offered' | 'awaiting_choice' | 'active' | 'completed';
+ /** True only on the first routing-layer match before any guideState has been persisted. */
+ isNewOffer?: boolean;
+ /** When user clicked an interactive selection, carries the chosen label. */
+ userSelection?: string;
+ };
/**
* F129: Compiled pack blocks from active packs.
* Injected into static identity via buildStaticIdentity → packBlocks.
@@ -601,6 +615,100 @@ export function buildInvocationContext(context: InvocationContext): string {
);
}
+ // F155: Guide candidate — inline protocol (cats don't have /Skill tool at runtime)
+ if (context.guideCandidate) {
+ const { id, name, estimatedTime, status } = context.guideCandidate;
+ const threadPart = context.threadId ? ` thread=${context.threadId}` : '';
+ const userSelection = context.guideCandidate.userSelection;
+ const isNewOffer = context.guideCandidate.isNewOffer === true;
+ // "先看步骤概览" still comes through as a chat message; start/skip are frontend-only actions
+ if ((status === 'offered' || status === 'awaiting_choice') && userSelection?.includes('步骤概览')) {
+ const previewSteps =
+ status === 'offered'
+ ? [
+ `1. 调用 cat_cafe_update_guide_state(threadId="${context.threadId}", guideId="${id}", status="awaiting_choice")`,
+ `2. 调用 cat_cafe_guide_resolve(intent="${name}") 获取步骤信息`,
+ '3. 用 3-5 条简要列出主要步骤',
+ '4. 在最后问用户是否要开始引导',
+ ]
+ : [
+ '1. 不要再次调用 cat_cafe_update_guide_state(当前已经是 awaiting_choice)',
+ `2. 调用 cat_cafe_guide_resolve(intent="${name}") 获取步骤信息`,
+ '3. 用 3-5 条简要列出主要步骤',
+ '4. 在最后问用户是否要开始引导',
+ ];
+ lines.push(
+ `🧭 Guide Selection:${threadPart} 用户选择了「步骤概览」 guideId=${id} name=${name}`,
+ '你必须按以下步骤回复:',
+ ...previewSteps,
+ '',
+ );
+ } else if (status === 'offered' && isNewOffer) {
+ // First encounter: emit interactive card with frontend-direct actions for start/skip
+ const blockJson = JSON.stringify({
+ id: `guide-offer-${id}-${(context.threadId ?? '').slice(-8) || 'x'}`,
+ kind: 'interactive',
+ v: 1,
+ interactiveType: 'select',
+ title: `我找到了「${name}」引导流程(约 ${estimatedTime})。要现在开始吗?`,
+ options: [
+ {
+ id: 'start',
+ label: '开始引导(推荐)',
+ emoji: '🚀',
+ action: {
+ type: 'callback',
+ endpoint: '/api/guide-actions/start',
+ payload: { threadId: context.threadId, guideId: id },
+ },
+ },
+ { id: 'preview', label: '先看步骤概览', emoji: '📋' },
+ {
+ id: 'skip',
+ label: '暂不需要',
+ emoji: '⏭️',
+ action: {
+ type: 'callback',
+ endpoint: '/api/guide-actions/cancel',
+ payload: { threadId: context.threadId, guideId: id },
+ },
+ },
+ ],
+ messageTemplate: '引导流程:{selection}',
+ });
+ lines.push(
+ `🧭 Guide Matched:${threadPart} id=${id} name=${name} time=${estimatedTime}`,
+ '你必须按以下步骤回复(严格遵守):',
+ `1. 调用 cat_cafe_update_guide_state(threadId="${context.threadId}", guideId="${id}", status="offered")`,
+ `2. 写一句简短的话告知用户你找到了「${name}」引导流程`,
+ `3. 调用 cat_cafe_create_rich_block,block 参数传入以下 JSON 字符串:`,
+ blockJson,
+ '4. 禁止直接给出教程或步骤列表',
+ '5. 禁止调用 cat_cafe_start_guide(等用户在选项卡中选择后再启动)',
+ '',
+ );
+ } else if (status === 'offered' || status === 'awaiting_choice') {
+ lines.push(
+ `🧭 Guide Pending:${threadPart} id=${id} name=${name} — 用户尚未选择`,
+ '不要重复发送选项卡。用一句话提醒:「之前找到了引导流程,你要开始吗?」',
+ '',
+ );
+ } else if (status === 'active') {
+ lines.push(
+ `🧭 Guide Active:${threadPart} id=${id} name=${name}`,
+ '引导进行中。回答与引导相关的问题,不要重发选项卡。用户要退出时调用 cat_cafe_guide_control(action="exit")。',
+ '',
+ );
+ } else if (status === 'completed') {
+ lines.push(
+ `🧭 Guide Completed:${threadPart} id=${id} name=${name}`,
+ '用户刚完成了这个引导流程。用一句话肯定用户的操作(如"添加成员成功了"),并询问是否需要进一步帮助。不要重发选项卡。',
+ '',
+ );
+ }
+ // cancelled: no injection needed
+ }
+
// F091: Active Signal articles in discussion context
if (context.activeSignals && context.activeSignals.length > 0) {
lines.push('Signal articles linked to this thread:');
diff --git a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts
index 34eabc3b1..8e34a266b 100644
--- a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts
+++ b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts
@@ -130,6 +130,8 @@ export interface Thread {
deletedAt?: number | null;
/** F087: CVO Bootcamp onboarding state. */
bootcampState?: BootcampStateV1;
+ /** F155: Scene-based bidirectional guide state. */
+ guideState?: GuideStateV1;
/** F088 Phase G: Connector Hub thread state — marks this thread as an IM Hub for command isolation. */
connectorHubState?: ConnectorHubStateV1;
}
@@ -174,6 +176,26 @@ export interface BootcampStateV1 {
completedAt?: number;
}
+/** F155: Guide session status */
+export type GuideStatus = 'offered' | 'awaiting_choice' | 'active' | 'completed' | 'cancelled';
+
+/** F155: Scene-based bidirectional guide state — thread-level authority */
+export interface GuideStateV1 {
+ v: 1;
+ guideId: string;
+ status: GuideStatus;
+ /** Owning user for default-thread guide state. */
+ userId?: string;
+ currentStep?: number;
+ offeredAt: number;
+ startedAt?: number;
+ completedAt?: number;
+ /** True after the first agent turn has seen the completion (one-shot consumption). */
+ completionAcked?: boolean;
+ /** catId that offered this guide (prevents multi-cat duplicate offers). */
+ offeredBy?: string;
+}
+
/** F079: Voting state stored in thread metadata */
export interface VotingStateV1 {
v: 1;
@@ -246,6 +268,8 @@ export interface IThreadStore {
updateVoiceMode(threadId: string, voiceMode: boolean): void | Promise;
/** F087: Get/update bootcamp state. */
updateBootcampState(threadId: string, state: BootcampStateV1 | null): void | Promise;
+ /** F155: Get/update guide state. */
+ updateGuideState(threadId: string, state: GuideStateV1 | null): void | Promise;
/** F088 Phase G: Get/update connector hub state. */
updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): void | Promise;
updateLastActive(threadId: string): void | Promise;
@@ -555,6 +579,16 @@ export class ThreadStore implements IThreadStore {
}
}
+ updateGuideState(threadId: string, state: GuideStateV1 | null): void {
+ const thread = this.get(threadId);
+ if (!thread) return;
+ if (state === null) {
+ delete thread.guideState;
+ } else {
+ thread.guideState = state;
+ }
+ }
+
updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): void {
const thread = this.get(threadId);
if (!thread) return;
diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts
index 336034e25..5c0b699c9 100644
--- a/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts
+++ b/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts
@@ -16,6 +16,7 @@ import type { RedisClient } from '@cat-cafe/shared/utils';
import type {
BootcampStateV1,
ConnectorHubStateV1,
+ GuideStateV1,
IThreadStore,
MentionActionabilityMode,
Thread,
@@ -443,6 +444,15 @@ export class RedisThreadStore implements IThreadStore {
}
}
+ async updateGuideState(threadId: string, state: GuideStateV1 | null): Promise {
+ const key = ThreadKeys.detail(threadId);
+ if (state === null) {
+ await this.redis.hdel(key, 'guideState');
+ } else {
+ await this.redis.eval(HSET_IF_HAS_ID_LUA, 1, key, 'guideState', JSON.stringify(state));
+ }
+ }
+
async updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): Promise {
const key = ThreadKeys.detail(threadId);
if (state === null) {
@@ -748,6 +758,9 @@ export class RedisThreadStore implements IThreadStore {
if (thread.connectorHubState) {
result.connectorHubState = JSON.stringify(thread.connectorHubState);
}
+ if (thread.guideState) {
+ result.guideState = JSON.stringify(thread.guideState);
+ }
return result;
}
@@ -838,6 +851,16 @@ export class RedisThreadStore implements IThreadStore {
/* ignore malformed JSON */
}
}
+ if (data.guideState) {
+ try {
+ const parsed = JSON.parse(data.guideState);
+ if (parsed && typeof parsed === 'object' && parsed.v === 1) {
+ result.guideState = parsed as GuideStateV1;
+ }
+ } catch {
+ /* ignore malformed JSON */
+ }
+ }
return result;
}
diff --git a/packages/api/src/domains/guides/guide-registry-loader.ts b/packages/api/src/domains/guides/guide-registry-loader.ts
new file mode 100644
index 000000000..652dce621
--- /dev/null
+++ b/packages/api/src/domains/guides/guide-registry-loader.ts
@@ -0,0 +1,209 @@
+/**
+ * F155: Load guide registry from YAML source.
+ *
+ * Provides a validated set of known guide IDs for server-side validation,
+ * and the full registry entries for the resolve MCP tool.
+ */
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import YAML from 'yaml';
+
+export interface GuideRegistryEntry {
+ id: string;
+ name: string;
+ description: string;
+ keywords: string[];
+ category: string;
+ priority: string;
+ cross_system: boolean;
+ estimated_time: string;
+ flow_file: string;
+}
+
+interface RegistryFile {
+ guides: GuideRegistryEntry[];
+}
+
+/** Resolve project root from this file's location */
+function findProjectRoot(): string {
+ // At runtime: packages/api/dist/domains/guides/guide-registry-loader.js
+ const thisDir = dirname(fileURLToPath(import.meta.url));
+ return resolve(thisDir, '..', '..', '..', '..', '..');
+}
+
+let cachedEntries: GuideRegistryEntry[] | null = null;
+let cachedIds: Set | null = null;
+const GUIDE_TARGET_RE = /^[a-zA-Z0-9._-]+$/;
+
+function ensureLoaded(): void {
+ if (cachedEntries) return;
+ const root = findProjectRoot();
+ const registryPath = resolve(root, 'guides', 'registry.yaml');
+ const raw = readFileSync(registryPath, 'utf-8');
+ const parsed = YAML.parse(raw) as RegistryFile;
+ if (!parsed?.guides || !Array.isArray(parsed.guides)) {
+ throw new Error('[F155] Invalid guide registry: missing "guides" array');
+ }
+ cachedEntries = parsed.guides;
+ cachedIds = new Set(parsed.guides.map((g) => g.id));
+}
+
+/** Get set of valid guide IDs */
+export function getValidGuideIds(): Set {
+ ensureLoaded();
+ return cachedIds!;
+}
+
+/** Get all registry entries (for resolve tool) */
+export function getRegistryEntries(): GuideRegistryEntry[] {
+ ensureLoaded();
+ return cachedEntries!;
+}
+
+export function isValidGuideTarget(target: string): boolean {
+ return GUIDE_TARGET_RE.test(target);
+}
+
+/** Check if a guideId is valid */
+export function isValidGuideId(guideId: string): boolean {
+ return getValidGuideIds().has(guideId);
+}
+
+export interface GuideMatch {
+ id: string;
+ name: string;
+ description: string;
+ estimatedTime: string;
+ score: number;
+}
+
+/**
+ * Match user intent against guide registry keywords.
+ * Returns matched guides sorted by score (highest first), or empty array.
+ * Used by both the MCP callback endpoint and the pre-invocation routing hook.
+ */
+/* ── OrchestrationFlow v2 — runtime flow loader ── */
+
+export interface OrchestrationStep {
+ id: string;
+ target: string;
+ tips: string;
+ advance: 'click' | 'visible' | 'input' | 'confirm';
+ page?: string;
+ timeoutSec?: number;
+}
+
+export interface OrchestrationFlow {
+ id: string;
+ name: string;
+ description?: string;
+ steps: OrchestrationStep[];
+}
+
+interface RawFlowFile {
+ id: string;
+ name: string;
+ description?: string;
+ steps: Array<{
+ id: string;
+ target: string;
+ tips: string;
+ advance: string;
+ page?: string;
+ timeoutSec?: number;
+ }>;
+}
+
+const flowCache = new Map();
+const MIN_ASCII_REVERSE_MATCH_LENGTH = 3;
+const MIN_NON_ASCII_REVERSE_MATCH_LENGTH = 2;
+
+function normalizeGuideIntent(text: string): string {
+ return text.trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+function canUseReverseSubstringMatch(query: string): boolean {
+ const compact = query.replace(/\s+/g, '');
+ if (!compact) return false;
+ return /^[a-z0-9._-]+$/i.test(compact)
+ ? compact.length >= MIN_ASCII_REVERSE_MATCH_LENGTH
+ : compact.length >= MIN_NON_ASCII_REVERSE_MATCH_LENGTH;
+}
+
+/**
+ * Load a guide flow YAML at runtime and return OrchestrationFlow.
+ * Throws if guide ID is unknown or flow file is invalid.
+ */
+export function loadGuideFlow(guideId: string): OrchestrationFlow {
+ const cached = flowCache.get(guideId);
+ if (cached) return cached;
+
+ const entries = getRegistryEntries();
+ const entry = entries.find((e) => e.id === guideId);
+ if (!entry) throw new Error(`[F155] Unknown guide: ${guideId}`);
+
+ const root = findProjectRoot();
+ const flowPath = resolve(root, 'guides', entry.flow_file);
+ const raw = readFileSync(flowPath, 'utf-8');
+ const parsed = YAML.parse(raw) as RawFlowFile;
+
+ if (parsed?.id !== guideId) {
+ throw new Error(
+ `[F155] Invalid flow file for "${guideId}": expected id "${guideId}", got "${String(parsed?.id ?? '')}"`,
+ );
+ }
+
+ if (!parsed?.steps || !Array.isArray(parsed.steps)) {
+ throw new Error(`[F155] Invalid flow file for "${guideId}": missing steps`);
+ }
+
+ const validAdvance = new Set(['click', 'visible', 'input', 'confirm']);
+ const flow: OrchestrationFlow = {
+ id: parsed.id,
+ name: parsed.name,
+ description: parsed.description,
+ steps: parsed.steps.map((s) => {
+ if (!validAdvance.has(s.advance)) {
+ throw new Error(`[F155] Invalid advance type "${s.advance}" in step "${s.id}"`);
+ }
+ if (!isValidGuideTarget(s.target)) {
+ throw new Error(`[F155] Invalid target "${s.target}" in step "${s.id}"`);
+ }
+ return {
+ id: s.id,
+ target: s.target,
+ tips: s.tips,
+ advance: s.advance as OrchestrationStep['advance'],
+ ...(s.page && { page: s.page }),
+ ...(s.timeoutSec && { timeoutSec: s.timeoutSec }),
+ };
+ }),
+ };
+
+ flowCache.set(guideId, flow);
+ return flow;
+}
+
+export function resolveGuideForIntent(intent: string): GuideMatch[] {
+ const entries = getRegistryEntries();
+ const query = normalizeGuideIntent(intent);
+ if (!query) return [];
+ const allowReverseSubstringMatch = canUseReverseSubstringMatch(query);
+ return entries
+ .map((entry) => {
+ const score = entry.keywords.filter((kw) => {
+ const normalizedKeyword = normalizeGuideIntent(kw);
+ return query.includes(normalizedKeyword) || (allowReverseSubstringMatch && normalizedKeyword.includes(query));
+ }).length;
+ return {
+ id: entry.id,
+ name: entry.name,
+ description: entry.description,
+ estimatedTime: entry.estimated_time,
+ score,
+ };
+ })
+ .filter((e) => e.score > 0)
+ .sort((a, b) => b.score - a.score);
+}
diff --git a/packages/api/src/domains/guides/guide-state-access.ts b/packages/api/src/domains/guides/guide-state-access.ts
new file mode 100644
index 000000000..7f54f6a19
--- /dev/null
+++ b/packages/api/src/domains/guides/guide-state-access.ts
@@ -0,0 +1,30 @@
+import { DEFAULT_THREAD_ID, type GuideStateV1 } from '../cats/services/stores/ports/ThreadStore.js';
+
+interface GuideThreadAccess {
+ id: string;
+ createdBy: string;
+}
+
+export function isSharedDefaultGuideThread(thread: GuideThreadAccess | null | undefined): boolean {
+ return Boolean(thread && thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system');
+}
+
+export function canAccessGuideState(
+ thread: GuideThreadAccess | null | undefined,
+ guideState: Pick | null | undefined,
+ userId: string,
+): boolean {
+ if (!thread || !guideState) return false;
+ if (thread.createdBy === userId) return true;
+ return isSharedDefaultGuideThread(thread) && guideState.userId === userId;
+}
+
+export function hasHiddenForeignNonTerminalGuideState(
+ thread: GuideThreadAccess | null | undefined,
+ guideState: Pick | null | undefined,
+ userId: string,
+): boolean {
+ if (!thread || !guideState) return false;
+ if (canAccessGuideState(thread, guideState, userId)) return false;
+ return guideState.status !== 'completed' && guideState.status !== 'cancelled';
+}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 49bef086b..e01007025 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -135,6 +135,7 @@ import {
externalProjectRoutes,
featureDocDetailRoutes,
governanceStatusRoute,
+ guideActionRoutes,
intentCardRoutes,
invocationsRoutes,
leaderboardEventsRoutes,
@@ -1141,6 +1142,10 @@ async function main(): Promise {
socketManager,
threadStore,
});
+ // F155: Frontend-facing guide actions (no MCP auth, uses userId header)
+ if (threadStore) {
+ await app.register(guideActionRoutes, { threadStore, socketManager });
+ }
await app.register(catsRoutes);
// F149 Phase C: ACP pool diagnostics endpoint (gated by env flag)
diff --git a/packages/api/src/routes/callback-guide-routes.ts b/packages/api/src/routes/callback-guide-routes.ts
new file mode 100644
index 000000000..9004d18e8
--- /dev/null
+++ b/packages/api/src/routes/callback-guide-routes.ts
@@ -0,0 +1,340 @@
+/**
+ * F155: Guide Callback Routes
+ * POST /api/callbacks/update-guide-state — update guide session state (forward-only, non-start transitions)
+ * POST /api/callbacks/start-guide — start a guide (validates offered→active)
+ * POST /api/callbacks/guide-resolve — resolve user intent to matching guides
+ * POST /api/callbacks/guide-control — control an active guide (next/back/skip/exit)
+ */
+
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js';
+import type { GuideStateV1, GuideStatus, IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
+import { canAccessGuideState } from '../domains/guides/guide-state-access.js';
+import type { SocketManager } from '../infrastructure/websocket/index.js';
+import { callbackAuthSchema } from './callback-auth-schema.js';
+import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js';
+
+// ---------------------------------------------------------------------------
+// State machine: valid transitions (forward-only DAG)
+// ---------------------------------------------------------------------------
+const VALID_TRANSITIONS: Record = {
+ offered: ['awaiting_choice', 'active', 'cancelled'],
+ awaiting_choice: ['active', 'cancelled'],
+ active: ['completed', 'cancelled'],
+ completed: [],
+ cancelled: [],
+};
+
+function isValidTransition(from: GuideStatus, to: GuideStatus): boolean {
+ return VALID_TRANSITIONS[from]?.includes(to) ?? false;
+}
+
+const guideStatusSchema = z.enum(['offered', 'awaiting_choice', 'active', 'completed', 'cancelled']);
+
+// ---------------------------------------------------------------------------
+// Schemas
+// ---------------------------------------------------------------------------
+const updateGuideStateSchema = callbackAuthSchema.extend({
+ threadId: z.string().min(1),
+ guideId: z.string().min(1),
+ status: guideStatusSchema,
+ currentStep: z.number().int().min(0).optional(),
+});
+
+const startGuideSchema = callbackAuthSchema.extend({
+ guideId: z.string().min(1),
+});
+
+const resolveGuideSchema = callbackAuthSchema.extend({
+ intent: z.string().min(1),
+});
+
+const controlGuideSchema = callbackAuthSchema.extend({
+ action: z.enum(['next', 'back', 'skip', 'exit']),
+});
+
+// ---------------------------------------------------------------------------
+// Registration
+// ---------------------------------------------------------------------------
+export async function registerCallbackGuideRoutes(
+ app: FastifyInstance,
+ deps: {
+ registry: InvocationRegistry;
+ threadStore: IThreadStore;
+ socketManager: SocketManager;
+ loadGuideFlow?: (guideId: string) => unknown;
+ },
+): Promise {
+ const { registry, threadStore, socketManager } = deps;
+ const log = app.log;
+
+ // Static ESM import — fail loudly if loader is broken
+ const {
+ isValidGuideId,
+ loadGuideFlow: defaultLoadGuideFlow,
+ resolveGuideForIntent,
+ } = await import('../domains/guides/guide-registry-loader.js');
+ const loadGuideFlow = deps.loadGuideFlow ?? defaultLoadGuideFlow;
+
+ // POST /api/callbacks/update-guide-state
+ app.post('/api/callbacks/update-guide-state', async (request, reply) => {
+ const parsed = updateGuideStateSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request body', details: parsed.error.issues };
+ }
+
+ const { invocationId, callbackToken, threadId, guideId, status, currentStep } = parsed.data;
+ const record = registry.verify(invocationId, callbackToken);
+ if (!record) {
+ reply.status(401);
+ return EXPIRED_CREDENTIALS_ERROR;
+ }
+
+ if (!registry.isLatest(invocationId)) {
+ return { status: 'stale_ignored' };
+ }
+
+ // Cross-thread binding check
+ if (record.threadId !== threadId) {
+ reply.status(403);
+ return { error: 'Cross-thread write rejected' };
+ }
+
+ const thread = await threadStore.get(threadId);
+ if (!thread) {
+ reply.status(404);
+ return { error: 'Thread not found' };
+ }
+
+ if (!isValidGuideId(guideId)) {
+ reply.status(400);
+ return { error: 'unknown_guide_id', message: `Guide "${guideId}" is not registered` };
+ }
+
+ const existing = thread.guideState;
+ const existingIsTerminal = existing?.status === 'completed' || existing?.status === 'cancelled';
+ if (existing && !existingIsTerminal && !canAccessGuideState(thread, existing, record.userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+
+ // First offer — no existing state
+ if (!existing) {
+ if (status !== 'offered') {
+ reply.status(400);
+ return { error: `Cannot create guide state with status "${status}" — must start as "offered"` };
+ }
+ const newState: GuideStateV1 = {
+ v: 1,
+ guideId,
+ status: 'offered',
+ userId: record.userId,
+ offeredAt: Date.now(),
+ offeredBy: record.catId ?? undefined,
+ };
+ await threadStore.updateGuideState(threadId, newState);
+ log.info({ guideId, threadId, catId: record.catId }, '[F155] guide state created: offered');
+ return { guideState: newState };
+ }
+
+ // Guide ID mismatch — reject (one active guide per thread)
+ if (existing.guideId !== guideId) {
+ // Allow new offer only if previous guide is terminal
+ if (existing.status !== 'completed' && existing.status !== 'cancelled') {
+ reply.status(409);
+ return {
+ error: 'guide_conflict',
+ message: `Thread has active guide "${existing.guideId}" in status "${existing.status}" — complete or cancel it first`,
+ };
+ }
+ // Previous guide is terminal, allow new offer
+ if (status !== 'offered') {
+ reply.status(400);
+ return { error: `Cannot create new guide state with status "${status}" — must start as "offered"` };
+ }
+ const newState: GuideStateV1 = {
+ v: 1,
+ guideId,
+ status: 'offered',
+ userId: record.userId,
+ offeredAt: Date.now(),
+ offeredBy: record.catId ?? undefined,
+ };
+ await threadStore.updateGuideState(threadId, newState);
+ log.info({ guideId, threadId }, '[F155] guide state replaced (previous was terminal)');
+ return { guideState: newState };
+ }
+
+ // Same guide, terminal state — allow fresh re-offer
+ if ((existing.status === 'completed' || existing.status === 'cancelled') && status === 'offered') {
+ const newState: GuideStateV1 = {
+ v: 1,
+ guideId,
+ status: 'offered',
+ userId: record.userId,
+ offeredAt: Date.now(),
+ offeredBy: record.catId ?? undefined,
+ };
+ await threadStore.updateGuideState(threadId, newState);
+ log.info({ guideId, threadId }, '[F155] guide re-offered after terminal state');
+ return { guideState: newState };
+ }
+
+ if (status === 'active') {
+ reply.status(400);
+ return {
+ error: 'guide_start_required',
+ message:
+ 'Use /api/callbacks/start-guide to transition a pending guide to "active" so guide_start side effects run',
+ };
+ }
+
+ // Same guide — validate non-start state transition
+ if (!isValidTransition(existing.status, status)) {
+ reply.status(400);
+ return {
+ error: `Invalid guide transition: ${existing.status} → ${status}`,
+ validTransitions: VALID_TRANSITIONS[existing.status],
+ };
+ }
+
+ const updated: GuideStateV1 = {
+ ...existing,
+ status,
+ ...(status === 'completed' || status === 'cancelled' ? { completedAt: Date.now() } : {}),
+ ...(currentStep !== undefined ? { currentStep } : {}),
+ };
+ await threadStore.updateGuideState(threadId, updated);
+ log.info({ guideId, threadId, transition: `${existing.status}→${status}` }, '[F155] guide state updated');
+ return { guideState: updated };
+ });
+
+ // POST /api/callbacks/start-guide — convenience route (validates offered/awaiting_choice → active)
+ app.post('/api/callbacks/start-guide', async (request, reply) => {
+ const parsed = startGuideSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+ const { invocationId, callbackToken, guideId } = parsed.data;
+ const record = registry.verify(invocationId, callbackToken);
+ if (!record) {
+ reply.status(401);
+ return EXPIRED_CREDENTIALS_ERROR;
+ }
+ if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' };
+ if (!isValidGuideId(guideId)) {
+ reply.status(400);
+ return { error: 'unknown_guide_id', message: `Guide "${guideId}" is not registered` };
+ }
+
+ // State validation: must be in offered or awaiting_choice
+ const thread = await threadStore.get(record.threadId);
+ const guideState = thread?.guideState;
+ if (!guideState || guideState.guideId !== guideId) {
+ reply.status(400);
+ return {
+ error: 'guide_not_offered',
+ message: `Guide "${guideId}" has not been offered in this thread — call update-guide-state first`,
+ };
+ }
+ if (!canAccessGuideState(thread, guideState, record.userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+ if (guideState.status !== 'offered' && guideState.status !== 'awaiting_choice') {
+ reply.status(400);
+ return {
+ error: `Cannot start guide in status "${guideState.status}" — must be "offered" or "awaiting_choice"`,
+ };
+ }
+
+ try {
+ loadGuideFlow(guideId);
+ } catch (err) {
+ reply.status(400);
+ log.warn({ guideId, threadId: record.threadId, err }, '[F155] callback start rejected — flow not loadable');
+ return { error: 'guide_flow_invalid', message: (err as Error).message };
+ }
+
+ // Transition to active
+ const updated: GuideStateV1 = { ...guideState, status: 'active', startedAt: Date.now() };
+ await threadStore.updateGuideState(record.threadId, updated);
+
+ // Guide UI events must stay user-scoped because the default thread is shared.
+ socketManager.emitToUser(record.userId, 'guide_start', {
+ guideId,
+ threadId: record.threadId,
+ timestamp: Date.now(),
+ });
+ log.info({ guideId, threadId: record.threadId }, '[F155] guide started (state: active)');
+ return { status: 'ok', guideId, guideState: updated };
+ });
+
+ // POST /api/callbacks/guide-resolve
+ app.post('/api/callbacks/guide-resolve', async (request, reply) => {
+ const parsed = resolveGuideSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+ const { invocationId, callbackToken, intent } = parsed.data;
+ const record = registry.verify(invocationId, callbackToken);
+ if (!record) {
+ reply.status(401);
+ return EXPIRED_CREDENTIALS_ERROR;
+ }
+
+ const matches = resolveGuideForIntent(intent);
+ log.info({ intent, matchCount: matches.length, threadId: record.threadId }, '[F155] guide_resolve');
+ return { status: 'ok', matches };
+ });
+
+ // POST /api/callbacks/guide-control — validates active state
+ app.post('/api/callbacks/guide-control', async (request, reply) => {
+ const parsed = controlGuideSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+ const { invocationId, callbackToken, action } = parsed.data;
+ const record = registry.verify(invocationId, callbackToken);
+ if (!record) {
+ reply.status(401);
+ return EXPIRED_CREDENTIALS_ERROR;
+ }
+ if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' };
+
+ // State validation: must have active guide
+ const thread = await threadStore.get(record.threadId);
+ const guideState = thread?.guideState;
+ if (!guideState || guideState.status !== 'active') {
+ reply.status(400);
+ return {
+ error: 'no_active_guide',
+ message: `No active guide in thread — current status: ${guideState?.status ?? 'none'}`,
+ };
+ }
+ if (!canAccessGuideState(thread, guideState, record.userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+
+ // Exit action → cancel guide
+ if (action === 'exit') {
+ const updated: GuideStateV1 = { ...guideState, status: 'cancelled', completedAt: Date.now() };
+ await threadStore.updateGuideState(record.threadId, updated);
+ }
+
+ socketManager.emitToUser(record.userId, 'guide_control', {
+ action,
+ guideId: guideState.guideId,
+ threadId: record.threadId,
+ timestamp: Date.now(),
+ });
+ log.info({ action, guideId: guideState.guideId, threadId: record.threadId }, '[F155] guide_control');
+ return { status: 'ok', action };
+ });
+}
diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts
index 825d70622..6e42ab8bb 100644
--- a/packages/api/src/routes/callbacks.ts
+++ b/packages/api/src/routes/callbacks.ts
@@ -34,6 +34,7 @@ import { registerCallbackBootcampRoutes } from './callback-bootcamp-routes.js';
import { registerCallbackDocumentRoutes } from './callback-document-routes.js';
import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js';
import { registerCallbackGameRoutes } from './callback-game-routes.js';
+import { registerCallbackGuideRoutes } from './callback-guide-routes.js';
import { registerCallbackLimbRoutes } from './callback-limb-routes.js';
import { registerCallbackMemoryRoutes } from './callback-memory-routes.js';
import { getMultiMentionOrchestrator, registerMultiMentionRoutes } from './callback-multi-mention-routes.js';
@@ -50,6 +51,8 @@ export interface CallbackRoutesOptions {
registry: InvocationRegistry;
messageStore: IMessageStore;
socketManager: SocketManager;
+ /** F155 review fix: allow tests to inject a failing guide flow loader. */
+ loadGuideFlow?: (guideId: string) => unknown;
taskStore?: ITaskStore;
backlogStore?: IBacklogStore;
/** For thinking mode filtering in thread-context + thread-cats discovery */
@@ -202,6 +205,13 @@ const richBlockSchema = z.discriminatedUnion('kind', [
group: z.string().optional(),
customInput: z.boolean().optional(),
customInputPlaceholder: z.string().optional(),
+ action: z
+ .object({
+ type: z.literal('callback'),
+ endpoint: z.string().min(1),
+ payload: z.record(z.unknown()).optional(),
+ })
+ .optional(),
}),
)
.min(1),
@@ -1349,4 +1359,14 @@ export const callbacksRoutes: FastifyPluginAsync = async
// F101: Game action callback for non-Claude cats (OpenCode/Codex/Gemini)
registerCallbackGameRoutes(app, { registry });
+
+ // F155: Guide engine — state-validated routes with ThreadStore authority
+ if (opts.threadStore) {
+ await registerCallbackGuideRoutes(app, {
+ registry,
+ threadStore: opts.threadStore,
+ socketManager,
+ ...(opts.loadGuideFlow ? { loadGuideFlow: opts.loadGuideFlow } : {}),
+ });
+ }
};
diff --git a/packages/api/src/routes/guide-action-routes.ts b/packages/api/src/routes/guide-action-routes.ts
new file mode 100644
index 000000000..ff1f6ec2d
--- /dev/null
+++ b/packages/api/src/routes/guide-action-routes.ts
@@ -0,0 +1,239 @@
+/**
+ * F155: Frontend-Facing Guide Action Routes
+ *
+ * These endpoints are called directly by the frontend InteractiveBlock
+ * when a user clicks a guide option with an `action` field.
+ * They use userId-based auth (X-Cat-Cafe-User header), NOT MCP callback auth.
+ *
+ * POST /api/guide-actions/start — start a guide (offered/awaiting_choice → active)
+ * POST /api/guide-actions/cancel — cancel a guide (offered/awaiting_choice → cancelled)
+ */
+import type { FastifyPluginAsync } from 'fastify';
+import { z } from 'zod';
+import {
+ DEFAULT_THREAD_ID,
+ type GuideStateV1,
+ type IThreadStore,
+} from '../domains/cats/services/stores/ports/ThreadStore.js';
+import { loadGuideFlow } from '../domains/guides/guide-registry-loader.js';
+import { canAccessGuideState } from '../domains/guides/guide-state-access.js';
+import type { SocketManager } from '../infrastructure/websocket/index.js';
+import { resolveHeaderUserId } from '../utils/request-identity.js';
+
+export interface GuideActionRoutesOptions {
+ threadStore: IThreadStore;
+ socketManager: SocketManager;
+}
+
+const startSchema = z.object({
+ threadId: z.string().min(1),
+ guideId: z.string().min(1),
+});
+
+const cancelSchema = z.object({
+ threadId: z.string().min(1),
+ guideId: z.string().min(1),
+});
+
+const completeSchema = z.object({
+ threadId: z.string().min(1),
+ guideId: z.string().min(1),
+});
+
+function canAccessGuideThread(thread: { id: string; createdBy: string } | null, userId: string): boolean {
+ if (!thread) return false;
+ return thread.createdBy === userId || (thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system');
+}
+
+export const guideActionRoutes: FastifyPluginAsync = async (app, opts) => {
+ const { threadStore, socketManager } = opts;
+ const log = app.log;
+
+ // POST /api/guide-actions/start — frontend clicks "开始引导"
+ app.post('/api/guide-actions/start', async (request, reply) => {
+ const userId = resolveHeaderUserId(request);
+ if (!userId) {
+ reply.status(401);
+ return { error: 'Identity required (X-Cat-Cafe-User header)' };
+ }
+
+ const parsed = startSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+
+ const { threadId, guideId } = parsed.data;
+ const thread = await threadStore.get(threadId);
+ if (!thread) {
+ reply.status(404);
+ return { error: 'Thread not found' };
+ }
+
+ if (!canAccessGuideThread(thread, userId)) {
+ reply.status(403);
+ return { error: 'Thread access denied' };
+ }
+
+ const gs = thread.guideState;
+ if (!gs || gs.guideId !== guideId) {
+ reply.status(400);
+ return { error: 'guide_not_offered', message: `Guide "${guideId}" not offered in this thread` };
+ }
+ if (!canAccessGuideState(thread, gs, userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+ if (gs.status !== 'offered' && gs.status !== 'awaiting_choice') {
+ reply.status(400);
+ return { error: `Cannot start guide in status "${gs.status}"` };
+ }
+
+ // P1 fix: validate flow is loadable before committing state transition
+ try {
+ loadGuideFlow(guideId);
+ } catch (err) {
+ log.warn({ guideId, threadId, err }, '[F155] start rejected — flow not loadable');
+ reply.status(400);
+ return { error: 'guide_flow_invalid', message: (err as Error).message };
+ }
+
+ const updated: GuideStateV1 = { ...gs, status: 'active', startedAt: Date.now() };
+ await threadStore.updateGuideState(threadId, updated);
+
+ // Guide UI events must stay user-scoped because the default thread is shared.
+ socketManager.emitToUser(userId, 'guide_start', {
+ guideId,
+ threadId,
+ timestamp: Date.now(),
+ });
+ log.info({ guideId, threadId, userId }, '[F155] guide started via frontend action');
+ return { status: 'ok', guideId, guideState: updated };
+ });
+
+ // GET /api/guide-flows/:guideId — serve flow definition at runtime
+ app.get<{ Params: { guideId: string } }>('/api/guide-flows/:guideId', async (request, reply) => {
+ const userId = resolveHeaderUserId(request);
+ if (!userId) {
+ reply.status(401);
+ return { error: 'Identity required (X-Cat-Cafe-User header)' };
+ }
+
+ const { guideId } = request.params;
+ try {
+ const flow = loadGuideFlow(guideId);
+ return flow;
+ } catch (err) {
+ log.warn({ guideId, err }, '[F155] Failed to load guide flow');
+ reply.status(404);
+ return { error: 'guide_not_found', message: (err as Error).message };
+ }
+ });
+
+ // POST /api/guide-actions/cancel — frontend clicks "暂不需要"
+ app.post('/api/guide-actions/cancel', async (request, reply) => {
+ const userId = resolveHeaderUserId(request);
+ if (!userId) {
+ reply.status(401);
+ return { error: 'Identity required (X-Cat-Cafe-User header)' };
+ }
+
+ const parsed = cancelSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+
+ const { threadId, guideId } = parsed.data;
+ const thread = await threadStore.get(threadId);
+ if (!thread) {
+ reply.status(404);
+ return { error: 'Thread not found' };
+ }
+
+ if (!canAccessGuideThread(thread, userId)) {
+ reply.status(403);
+ return { error: 'Thread access denied' };
+ }
+
+ const gs = thread.guideState;
+ if (!gs || gs.guideId !== guideId) {
+ reply.status(400);
+ return { error: 'guide_not_offered', message: `Guide "${guideId}" not offered in this thread` };
+ }
+ if (!canAccessGuideState(thread, gs, userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+ if (gs.status === 'completed' || gs.status === 'cancelled') {
+ return { status: 'ok', guideState: gs };
+ }
+
+ const updated: GuideStateV1 = { ...gs, status: 'cancelled', completedAt: Date.now() };
+ await threadStore.updateGuideState(threadId, updated);
+
+ socketManager.emitToUser(userId, 'guide_control', {
+ action: 'exit',
+ guideId,
+ threadId,
+ timestamp: Date.now(),
+ });
+ log.info({ guideId, threadId, userId }, '[F155] guide cancelled via frontend action');
+ return { status: 'ok', guideState: updated };
+ });
+
+ // POST /api/guide-actions/complete — frontend guide overlay finished all steps
+ app.post('/api/guide-actions/complete', async (request, reply) => {
+ const userId = resolveHeaderUserId(request);
+ if (!userId) {
+ reply.status(401);
+ return { error: 'Identity required (X-Cat-Cafe-User header)' };
+ }
+
+ const parsed = completeSchema.safeParse(request.body);
+ if (!parsed.success) {
+ reply.status(400);
+ return { error: 'Invalid request', details: parsed.error.issues };
+ }
+
+ const { threadId, guideId } = parsed.data;
+ const thread = await threadStore.get(threadId);
+ if (!thread) {
+ reply.status(404);
+ return { error: 'Thread not found' };
+ }
+
+ if (!canAccessGuideThread(thread, userId)) {
+ reply.status(403);
+ return { error: 'Thread access denied' };
+ }
+
+ const gs = thread.guideState;
+ if (!gs || gs.guideId !== guideId) {
+ reply.status(400);
+ return { error: 'guide_not_active', message: `Guide "${guideId}" not active in this thread` };
+ }
+ if (!canAccessGuideState(thread, gs, userId)) {
+ reply.status(403);
+ return { error: 'Guide access denied' };
+ }
+ if (gs.status === 'completed') {
+ return { status: 'ok', guideState: gs };
+ }
+ if (gs.status !== 'active') {
+ reply.status(400);
+ return { error: `Cannot complete guide in status "${gs.status}"` };
+ }
+
+ const updated: GuideStateV1 = { ...gs, status: 'completed', completedAt: Date.now() };
+ await threadStore.updateGuideState(threadId, updated);
+
+ socketManager.emitToUser(userId, 'guide_complete', {
+ guideId,
+ threadId,
+ timestamp: Date.now(),
+ });
+ log.info({ guideId, threadId, userId }, '[F155] guide completed via frontend action');
+ return { status: 'ok', guideId, guideState: updated };
+ });
+};
diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts
index d8d03547b..ec72817b1 100644
--- a/packages/api/src/routes/index.ts
+++ b/packages/api/src/routes/index.ts
@@ -22,6 +22,7 @@ export { externalProjectRoutes } from './external-projects.js';
export { featureDocDetailRoutes } from './feature-doc-detail.js';
export { gameRoutes } from './games.js';
export { governanceStatusRoute } from './governance-status.js';
+export { guideActionRoutes } from './guide-action-routes.js';
export { intentCardRoutes } from './intent-card-routes.js';
export { invocationsRoutes } from './invocations.js';
export { leaderboardRoutes } from './leaderboard.js';
diff --git a/packages/api/src/routes/session-chain.ts b/packages/api/src/routes/session-chain.ts
index 79501acbd..6ca48b667 100644
--- a/packages/api/src/routes/session-chain.ts
+++ b/packages/api/src/routes/session-chain.ts
@@ -17,7 +17,7 @@ import type { ISessionSealer } from '../domains/cats/services/session/SessionSea
import type { TranscriptReader } from '../domains/cats/services/session/TranscriptReader.js';
import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js';
import type { ISessionChainStore } from '../domains/cats/services/stores/ports/SessionChainStore.js';
-import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
+import { DEFAULT_THREAD_ID, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
import { resolveUserId } from '../utils/request-identity.js';
const bindSessionSchema = z.object({
@@ -32,6 +32,27 @@ interface SessionChainRouteOptions extends FastifyPluginOptions {
sessionSealer?: ISessionSealer;
}
+function canAccessThread(thread: { id: string; createdBy: string } | null, userId: string): boolean {
+ if (!thread) return false;
+ if (thread.createdBy === userId) return true;
+ // Only the real default thread is globally readable/writable.
+ return thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system';
+}
+
+function isSharedDefaultThread(thread: { id: string; createdBy: string } | null): boolean {
+ return Boolean(thread && thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system');
+}
+
+function canAccessSessionRecord(
+ thread: { id: string; createdBy: string } | null,
+ session: { userId: string } | null,
+ userId: string,
+): boolean {
+ if (!thread || !session) return false;
+ if (thread.createdBy === userId) return true;
+ return isSharedDefaultThread(thread) && session.userId === userId;
+}
+
export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChainRouteOptions): Promise {
const { sessionChainStore, threadStore, messageStore, transcriptReader, sessionSealer } = opts;
@@ -47,7 +68,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai
const { threadId } = request.params;
const thread = await threadStore.get(threadId);
- if (!thread || thread.createdBy !== userId) {
+ if (!canAccessThread(thread, userId)) {
reply.status(403);
return { error: 'Access denied' };
}
@@ -65,12 +86,18 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai
return { error: `Cannot query sessions for cat '${catId}' — you are '${callerCatId}'` };
}
const sessions = await sessionChainStore.getChain(effectiveCatId as CatId, threadId);
- return reply.send({ sessions });
+ const visibleSessions = isSharedDefaultThread(thread)
+ ? sessions.filter((session) => session.userId === userId)
+ : sessions;
+ return reply.send({ sessions: visibleSessions });
}
- // No catId filter at all (hub UI god-view) — return all sessions for the thread
+ // No catId filter at all (hub UI god-view) — default thread stays user-scoped.
const sessions = await sessionChainStore.getChainByThread(threadId);
- return reply.send({ sessions });
+ const visibleSessions = isSharedDefaultThread(thread)
+ ? sessions.filter((session) => session.userId === userId)
+ : sessions;
+ return reply.send({ sessions: visibleSessions });
});
app.get<{
@@ -94,7 +121,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai
reply.status(404);
return { error: 'Thread not found' };
}
- if (thread.createdBy !== userId) {
+ if (!canAccessThread(thread, userId) || !canAccessSessionRecord(thread, session, userId)) {
reply.status(403);
return { error: 'Access denied' };
}
@@ -125,7 +152,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai
reply.status(404);
return { error: 'Thread not found' };
}
- if (thread.createdBy !== userId) {
+ if (!canAccessThread(thread, userId) || !canAccessSessionRecord(thread, session, userId)) {
reply.status(403);
return { error: 'Access denied' };
}
@@ -246,13 +273,17 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai
reply.status(404);
return { error: 'Thread not found' };
}
- if (thread.createdBy !== userId) {
+ if (!canAccessThread(thread, userId)) {
reply.status(403);
return { error: 'Access denied' };
}
// Check for active session
const active = await sessionChainStore.getActive(catId as CatId, threadId);
+ if (active && !canAccessSessionRecord(thread, active, userId)) {
+ reply.status(403);
+ return { error: 'Access denied' };
+ }
let session;
let mode: 'updated' | 'created';
diff --git a/packages/api/test/callback-guide-routes.test.js b/packages/api/test/callback-guide-routes.test.js
new file mode 100644
index 000000000..089bbce30
--- /dev/null
+++ b/packages/api/test/callback-guide-routes.test.js
@@ -0,0 +1,285 @@
+/**
+ * F155: Guide engine callback route tests
+ * Tests: start-guide, guide-resolve, guide-control
+ */
+
+import assert from 'node:assert/strict';
+import { beforeEach, describe, test } from 'node:test';
+import Fastify from 'fastify';
+import './helpers/setup-cat-registry.js';
+
+describe('F155 Guide callback routes', () => {
+ let registry;
+ let messageStore;
+ let threadStore;
+ let socketManager;
+ let broadcasts;
+ let emits;
+
+ beforeEach(async () => {
+ const { InvocationRegistry } = await import(
+ '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'
+ );
+ const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js');
+ const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js');
+
+ registry = new InvocationRegistry();
+ messageStore = new MessageStore();
+ threadStore = new ThreadStore();
+ broadcasts = [];
+ emits = [];
+
+ socketManager = {
+ broadcastAgentMessage() {},
+ broadcastToRoom(room, event, data) {
+ broadcasts.push({ room, event, data });
+ },
+ emitToUser(userId, event, data) {
+ emits.push({ userId, event, data });
+ },
+ };
+ });
+
+ async function createApp(overrides = {}) {
+ const { callbacksRoutes } = await import('../dist/routes/callbacks.js');
+ const app = Fastify();
+ await app.register(callbacksRoutes, {
+ registry,
+ messageStore,
+ socketManager,
+ threadStore,
+ ...overrides,
+ });
+ return app;
+ }
+
+ function createCreds() {
+ const thread = threadStore.create('user-1', 'Test');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+ return { invocationId, callbackToken, threadId: thread.id };
+ }
+
+ async function seedGuideState(threadId, guideId, status) {
+ await threadStore.updateGuideState(threadId, {
+ v: 1,
+ guideId,
+ status,
+ offeredAt: Date.now(),
+ ...(status === 'active' ? { startedAt: Date.now() } : {}),
+ });
+ }
+
+ // ─── start-guide ───
+
+ describe('POST /api/callbacks/start-guide', () => {
+ test('starts guide with valid guideId', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken, threadId } = createCreds();
+ await seedGuideState(threadId, 'add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.status, 'ok');
+ assert.equal(body.guideId, 'add-member');
+
+ assert.equal(broadcasts.length, 0);
+ assert.deepEqual(emits, [
+ {
+ userId: 'user-1',
+ event: 'guide_start',
+ data: {
+ guideId: 'add-member',
+ threadId,
+ timestamp: emits[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emits[0].data.timestamp, 'number');
+ });
+
+ test('rejects unknown guideId', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken } = createCreds();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'nonexistent-flow' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ const body = JSON.parse(res.body);
+ assert.equal(body.error, 'unknown_guide_id');
+ assert.equal(broadcasts.length, 0);
+ });
+
+ test('rejects expired credentials', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId: 'fake', callbackToken: 'fake', guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ assert.equal(broadcasts.length, 0);
+ });
+
+ test('returns stale_ignored for non-latest invocation', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken, threadId } = createCreds();
+ // Create a newer invocation to make the first one stale
+ registry.create('user-1', 'opus', threadId);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ const body = JSON.parse(res.body);
+ assert.equal(body.status, 'stale_ignored');
+ assert.equal(broadcasts.length, 0);
+ });
+
+ test('rejects callback start when guide flow is not loadable', async () => {
+ const app = await createApp({
+ loadGuideFlow() {
+ throw new Error('broken flow yaml');
+ },
+ });
+ const { invocationId, callbackToken, threadId } = createCreds();
+ await seedGuideState(threadId, 'add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ const body = JSON.parse(res.body);
+ assert.equal(body.error, 'guide_flow_invalid');
+ assert.equal(body.message, 'broken flow yaml');
+ assert.equal((await threadStore.get(threadId)).guideState.status, 'offered');
+ assert.equal(broadcasts.length, 0);
+ });
+ });
+
+ // ─── guide-resolve ───
+
+ describe('POST /api/callbacks/guide-resolve', () => {
+ test('resolves matching intent', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken } = createCreds();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-resolve',
+ payload: { invocationId, callbackToken, intent: '添加成员' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.status, 'ok');
+ assert.ok(body.matches.length > 0);
+ assert.equal(body.matches[0].id, 'add-member');
+ });
+
+ test('returns empty matches for unrelated intent', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken } = createCreds();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-resolve',
+ payload: { invocationId, callbackToken, intent: '天气预报' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.status, 'ok');
+ assert.equal(body.matches.length, 0);
+ });
+
+ test('rejects expired credentials', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-resolve',
+ payload: { invocationId: 'fake', callbackToken: 'fake', intent: '添加' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ });
+ });
+
+ // ─── guide-control ───
+
+ describe('POST /api/callbacks/guide-control', () => {
+ test('emits control action to the invocation user with valid credentials', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken, threadId } = createCreds();
+ await seedGuideState(threadId, 'add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'next' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.status, 'ok');
+ assert.equal(body.action, 'next');
+ assert.equal(broadcasts.length, 0);
+ assert.deepEqual(emits, [
+ {
+ userId: 'user-1',
+ event: 'guide_control',
+ data: {
+ action: 'next',
+ guideId: 'add-member',
+ threadId,
+ timestamp: emits[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emits[0].data.timestamp, 'number');
+ });
+
+ test('rejects invalid action', async () => {
+ const app = await createApp();
+ const { invocationId, callbackToken } = createCreds();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'destroy' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('rejects expired credentials', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId: 'fake', callbackToken: 'fake', action: 'next' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ assert.equal(broadcasts.length, 0);
+ });
+ });
+});
diff --git a/packages/api/test/callback-guide-state.test.js b/packages/api/test/callback-guide-state.test.js
new file mode 100644
index 000000000..ee2c649f9
--- /dev/null
+++ b/packages/api/test/callback-guide-state.test.js
@@ -0,0 +1,533 @@
+/**
+ * F155: Guide State Callback Tests
+ * POST /api/callbacks/update-guide-state
+ * POST /api/callbacks/start-guide
+ * POST /api/callbacks/guide-control
+ *
+ * Tests forward-only state machine, multi-cat dedup, and authorization.
+ */
+
+import assert from 'node:assert/strict';
+import { beforeEach, describe, test } from 'node:test';
+import Fastify from 'fastify';
+import './helpers/setup-cat-registry.js';
+
+describe('F155 Guide State Callbacks', () => {
+ let registry;
+ let threadStore;
+ let messageStore;
+ let socketManager;
+ let broadcastCalls;
+ let emitCalls;
+
+ beforeEach(async () => {
+ const { InvocationRegistry } = await import(
+ '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'
+ );
+ const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js');
+ const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js');
+
+ registry = new InvocationRegistry();
+ threadStore = new ThreadStore();
+ messageStore = new MessageStore();
+ broadcastCalls = [];
+ emitCalls = [];
+ socketManager = {
+ broadcastAgentMessage() {},
+ broadcastToRoom(room, event, data) {
+ broadcastCalls.push({ room, event, data });
+ },
+ emitToUser(userId, event, data) {
+ emitCalls.push({ userId, event, data });
+ },
+ getMessages() {
+ return [];
+ },
+ };
+ });
+
+ async function createApp() {
+ const { callbacksRoutes } = await import('../dist/routes/callbacks.js');
+ const { leaderboardEventsRoutes } = await import('../dist/routes/leaderboard-events.js');
+ const { GameStore } = await import('../dist/domains/leaderboard/game-store.js');
+ const { AchievementStore } = await import('../dist/domains/leaderboard/achievement-store.js');
+ const app = Fastify();
+ await app.register(callbacksRoutes, {
+ registry,
+ messageStore,
+ socketManager,
+ threadStore,
+ sharedBank: 'cat-cafe-shared',
+ });
+ await app.register(leaderboardEventsRoutes, {
+ gameStore: new GameStore(),
+ achievementStore: new AchievementStore(),
+ });
+ return app;
+ }
+
+ async function seedDefaultThread(guideId, status, userId = 'default-user') {
+ const thread = await threadStore.get('default');
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId,
+ status,
+ offeredAt: Date.now(),
+ ...(status === 'active' ? { startedAt: Date.now() } : {}),
+ userId,
+ });
+ return thread;
+ }
+
+ // --- update-guide-state ---
+
+ test('creates initial guide state as "offered"', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.guideState.guideId, 'add-member');
+ assert.equal(body.guideState.status, 'offered');
+ assert.ok(body.guideState.offeredAt > 0);
+ });
+
+ test('rejects initial state that is not "offered"', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('update-guide-state rejects active transition and requires start-guide side effects', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: 1000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ const body = JSON.parse(res.body);
+ assert.equal(body.error, 'guide_start_required');
+ assert.match(body.message, /start-guide/i);
+
+ const stored = await threadStore.get(thread.id);
+ assert.equal(stored.guideState.status, 'offered');
+ assert.equal(stored.guideState.offeredAt, 1000);
+ assert.deepEqual(emitCalls, []);
+ assert.deepEqual(broadcastCalls, []);
+ });
+
+ test('rejects invalid backward transition: active → offered', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'active',
+ offeredAt: 1000,
+ startedAt: 2000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ const body = JSON.parse(res.body);
+ assert.ok(body.error.includes('Invalid guide transition'));
+ });
+
+ test('rejects re-offering same guide when it is still active', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'active',
+ offeredAt: 1000,
+ startedAt: 2000,
+ });
+
+ // Trying to go back to 'offered' is an invalid backward transition
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ assert.ok(JSON.parse(res.body).error.includes('Invalid guide transition'));
+ });
+
+ test('allows re-offering same guide after it was completed', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: 1000,
+ completedAt: 3000,
+ });
+
+ // completed is terminal; completed→offered is not a normal transition,
+ // but the route should treat "same guideId + terminal state" as a new offer
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(JSON.parse(res.body).guideState.guideId, 'add-member');
+ assert.equal(JSON.parse(res.body).guideState.status, 'offered');
+ });
+
+ test('rejects cross-thread write', async () => {
+ const app = await createApp();
+ const thread1 = await threadStore.create('user-1', 'thread-1');
+ const thread2 = await threadStore.create('user-1', 'thread-2');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread1.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread2.id, guideId: 'add-member', status: 'offered' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ });
+
+ test('returns 401 for invalid credentials', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: {
+ invocationId: 'fake',
+ callbackToken: 'fake',
+ threadId: 't1',
+ guideId: 'add-member',
+ status: 'offered',
+ },
+ });
+
+ assert.equal(res.statusCode, 401);
+ });
+
+ test('update-guide-state rejects cross-user mutation on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner');
+ const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/update-guide-state',
+ payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ const stored = await threadStore.get(thread.id);
+ assert.equal(stored.guideState.status, 'offered');
+ assert.equal(stored.guideState.userId, 'guide-owner');
+ });
+
+ // --- start-guide ---
+
+ test('start-guide transitions from offered → active and emits socket event', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: 1000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.guideState.status, 'active');
+
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_start'),
+ undefined,
+ 'guide_start should be user-scoped',
+ );
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'user-1',
+ event: 'guide_start',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('start-guide emits guide_start only to the guide owner on shared default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner');
+ const { invocationId, callbackToken } = registry.create('guide-owner', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_start'),
+ undefined,
+ 'shared default-thread guide_start must not use room broadcast',
+ );
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'guide-owner',
+ event: 'guide_start',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('start-guide rejects when no guide is offered', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ assert.ok(JSON.parse(res.body).error.includes('guide_not_offered'));
+ });
+
+ test('start-guide rejects when guide is already active', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'active',
+ offeredAt: 1000,
+ startedAt: 2000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('start-guide rejects cross-user start on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner');
+ const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/start-guide',
+ payload: { invocationId, callbackToken, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ const stored = await threadStore.get(thread.id);
+ assert.equal(stored.guideState.status, 'offered');
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_start'),
+ undefined,
+ );
+ });
+
+ // --- guide-control ---
+
+ test('guide-control emits socket event when guide is active', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'active',
+ offeredAt: 1000,
+ startedAt: 2000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'next' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_control'),
+ undefined,
+ 'guide_control should be user-scoped',
+ );
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'user-1',
+ event: 'guide_control',
+ data: {
+ action: 'next',
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('guide-control emits only to the guide owner on shared default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'active', 'guide-owner');
+ const { invocationId, callbackToken } = registry.create('guide-owner', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'next' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_control'),
+ undefined,
+ 'shared default-thread guide_control must not use room broadcast',
+ );
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'guide-owner',
+ event: 'guide_control',
+ data: {
+ action: 'next',
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('guide-control rejects when no active guide', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'next' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ assert.ok(JSON.parse(res.body).error.includes('no_active_guide'));
+ });
+
+ test('guide-control exit cancels the guide', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+ const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id);
+
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId: 'add-member',
+ status: 'active',
+ offeredAt: 1000,
+ startedAt: 2000,
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'exit' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const updatedThread = await threadStore.get(thread.id);
+ assert.equal(updatedThread.guideState.status, 'cancelled');
+ });
+
+ test('guide-control rejects cross-user control on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'active', 'guide-owner');
+ const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/callbacks/guide-control',
+ payload: { invocationId, callbackToken, action: 'exit' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ const stored = await threadStore.get(thread.id);
+ assert.equal(stored.guideState.status, 'active');
+ assert.equal(
+ broadcastCalls.find((c) => c.event === 'guide_control'),
+ undefined,
+ );
+ });
+});
diff --git a/packages/api/test/guide-action-routes.test.js b/packages/api/test/guide-action-routes.test.js
new file mode 100644
index 000000000..058a55685
--- /dev/null
+++ b/packages/api/test/guide-action-routes.test.js
@@ -0,0 +1,607 @@
+/**
+ * F155: Frontend-Facing Guide Action Routes Tests
+ * POST /api/guide-actions/start — start guide via frontend click
+ * POST /api/guide-actions/cancel — cancel guide via frontend click
+ *
+ * These endpoints use userId-based auth (X-Cat-Cafe-User header),
+ * NOT MCP callback auth. They verify the frontend-only interaction path.
+ */
+
+import assert from 'node:assert/strict';
+import { beforeEach, describe, test } from 'node:test';
+import Fastify from 'fastify';
+import './helpers/setup-cat-registry.js';
+
+describe('F155 Guide Action Routes (frontend-facing)', () => {
+ let threadStore;
+ let socketManager;
+ let broadcastCalls;
+ let emitCalls;
+
+ beforeEach(async () => {
+ const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js');
+ threadStore = new ThreadStore();
+ broadcastCalls = [];
+ emitCalls = [];
+ socketManager = {
+ broadcastAgentMessage() {},
+ broadcastToRoom(room, event, data) {
+ broadcastCalls.push({ room, event, data });
+ },
+ emitToUser(userId, event, data) {
+ emitCalls.push({ userId, event, data });
+ },
+ getMessages() {
+ return [];
+ },
+ };
+ });
+
+ async function createApp() {
+ const { guideActionRoutes } = await import('../dist/routes/guide-action-routes.js');
+ const app = Fastify();
+ await app.register(guideActionRoutes, { threadStore, socketManager });
+ return app;
+ }
+
+ /** Seed a thread with guideState in given status */
+ async function seedThread(guideId, status, createdBy = 'user-1') {
+ const thread = await threadStore.create(createdBy, 'test-thread');
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId,
+ status,
+ offeredAt: Date.now(),
+ });
+ return thread;
+ }
+
+ async function seedDefaultThread(guideId, status, userId = 'default-user') {
+ const thread = await threadStore.get('default');
+ await threadStore.updateGuideState(thread.id, {
+ v: 1,
+ guideId,
+ status,
+ offeredAt: Date.now(),
+ userId,
+ });
+ return thread;
+ }
+
+ // --- /api/guide-actions/start ---
+
+ test('start: transitions offered → active and emits socket event', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.guideState.status, 'active');
+ assert.ok(body.guideState.startedAt);
+
+ // Verify socket event
+ assert.equal(broadcastCalls.length, 0);
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'user-1',
+ event: 'guide_start',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('start: transitions awaiting_choice → active', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'awaiting_choice');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(JSON.parse(res.body).guideState.status, 'active');
+ });
+
+ test('start: rejects when guide is already active', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('start: rejects when no guide offered', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, 'guide_not_offered');
+ });
+
+ test('start: rejects without user identity', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ });
+
+ // --- /api/guide-actions/cancel ---
+
+ test('cancel: transitions offered → cancelled and emits exit control event', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.guideState.status, 'cancelled');
+ assert.ok(body.guideState.completedAt);
+
+ assert.equal(broadcastCalls.length, 0);
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'user-1',
+ event: 'guide_control',
+ data: {
+ action: 'exit',
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('cancel: idempotent when already cancelled', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'cancelled');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(JSON.parse(res.body).guideState.status, 'cancelled');
+ });
+
+ test('cancel: rejects when guide not offered', async () => {
+ const app = await createApp();
+ const thread = await threadStore.create('user-1', 'test-thread');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('cancel: rejects without user identity', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ });
+
+ // --- P1: start must reject when flow is not loadable ---
+
+ test('start: rejects when guide flow is not loadable (400)', async () => {
+ const app = await createApp();
+ // Seed thread with a guideId that has no corresponding flow YAML
+ const thread = await seedThread('nonexistent-flow', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'nonexistent-flow' },
+ });
+
+ assert.equal(res.statusCode, 400, 'start must fail when flow cannot be loaded');
+ const body = JSON.parse(res.body);
+ assert.equal(body.error, 'guide_flow_invalid');
+ // Verify state was NOT updated to active
+ const updated = await threadStore.get(thread.id);
+ assert.equal(updated.guideState.status, 'offered', 'state must remain offered on flow load failure');
+ });
+
+ // --- P1-1: Thread ownership (cross-user state tampering) ---
+
+ test('start: rejects when user does not own the thread (403)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered'); // created by user-1
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'cross-user start must be rejected');
+ });
+
+ test('cancel: rejects when user does not own the thread (403)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered'); // created by user-1
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'cross-user cancel must be rejected');
+ });
+
+ // --- P2-1: Header-only auth (query param userId spoofing) ---
+
+ test('start: rejects query-param userId without header (401)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/guide-actions/start?userId=user-1`,
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401, 'query-param userId must not authenticate');
+ });
+
+ test('cancel: rejects query-param userId without header (401)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/guide-actions/cancel?userId=user-1`,
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401, 'query-param userId must not authenticate');
+ });
+
+ // --- Default thread (createdBy='system') public access ---
+
+ test('start: allows the guide owner on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'default-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200, 'default-thread guide owner should be allowed');
+ assert.equal(JSON.parse(res.body).guideState.status, 'active');
+ assert.equal(broadcastCalls.length, 0, 'guide_start must not broadcast to shared default thread room');
+ assert.equal(emitCalls.length, 1, 'guide_start must be emitted only to the guide owner');
+ assert.deepEqual(emitCalls[0], {
+ userId: 'default-user',
+ event: 'guide_start',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ });
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('start: rejects other users on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered', 'default-user');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped');
+ });
+
+ test('cancel: allows the guide owner on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'default-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200, 'default-thread guide owner should be allowed');
+ assert.equal(JSON.parse(res.body).guideState.status, 'cancelled');
+ assert.equal(broadcastCalls.length, 0, 'guide_control exit must not broadcast to shared default thread room');
+ assert.equal(emitCalls.length, 1, 'guide_control exit must be emitted only to the guide owner');
+ assert.deepEqual(emitCalls[0], {
+ userId: 'default-user',
+ event: 'guide_control',
+ data: {
+ action: 'exit',
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ });
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('cancel: rejects other users on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'offered', 'default-user');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped');
+ });
+
+ test('start: rejects arbitrary users on non-default system-owned threads', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered', 'system');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/start',
+ headers: { 'x-cat-cafe-user': 'any-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ assert.equal(JSON.parse(res.body).error, 'Thread access denied');
+ });
+
+ test('cancel: rejects arbitrary users on non-default system-owned threads', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered', 'system');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/cancel',
+ headers: { 'x-cat-cafe-user': 'any-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ assert.equal(JSON.parse(res.body).error, 'Thread access denied');
+ });
+
+ // --- /api/guide-flows/:guideId ---
+
+ test('guide-flows: rejects without user identity (401)', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/guide-flows/add-member',
+ });
+
+ assert.equal(res.statusCode, 401);
+ assert.equal(JSON.parse(res.body).error, 'Identity required (X-Cat-Cafe-User header)');
+ });
+
+ test('guide-flows: returns flow for authenticated users', async () => {
+ const app = await createApp();
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/guide-flows/add-member',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.id, 'add-member');
+ assert.ok(Array.isArray(body.steps));
+ assert.ok(body.steps.length > 0);
+ });
+
+ // --- /api/guide-actions/complete ---
+
+ test('complete: transitions active → completed and emits socket event', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.guideState.status, 'completed');
+ assert.ok(body.guideState.completedAt);
+
+ assert.equal(broadcastCalls.length, 0);
+ assert.deepEqual(emitCalls, [
+ {
+ userId: 'user-1',
+ event: 'guide_complete',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ },
+ ]);
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('complete: idempotent when already completed', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'completed');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(JSON.parse(res.body).guideState.status, 'completed');
+ });
+
+ test('complete: rejects when guide not active', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'offered');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'user-1' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 400);
+ });
+
+ test('complete: rejects when user does not own the thread (403)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'cross-user complete must be rejected');
+ });
+
+ test('complete: rejects without user identity (401)', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 401);
+ });
+
+ test('complete: allows the guide owner on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'active');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'default-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(JSON.parse(res.body).guideState.status, 'completed');
+ assert.equal(broadcastCalls.length, 0, 'guide_complete must not broadcast to shared default thread room');
+ assert.equal(emitCalls.length, 1, 'guide_complete must be emitted only to the guide owner');
+ assert.deepEqual(emitCalls[0], {
+ userId: 'default-user',
+ event: 'guide_complete',
+ data: {
+ guideId: 'add-member',
+ threadId: thread.id,
+ timestamp: emitCalls[0].data.timestamp,
+ },
+ });
+ assert.equal(typeof emitCalls[0].data.timestamp, 'number');
+ });
+
+ test('complete: rejects other users on system-owned default thread', async () => {
+ const app = await createApp();
+ const thread = await seedDefaultThread('add-member', 'active', 'default-user');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped');
+ });
+
+ test('complete: rejects arbitrary users on non-default system-owned threads', async () => {
+ const app = await createApp();
+ const thread = await seedThread('add-member', 'active', 'system');
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/guide-actions/complete',
+ headers: { 'x-cat-cafe-user': 'any-user' },
+ payload: { threadId: thread.id, guideId: 'add-member' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ assert.equal(JSON.parse(res.body).error, 'Thread access denied');
+ });
+});
diff --git a/packages/api/test/guide-registry-loader.test.js b/packages/api/test/guide-registry-loader.test.js
new file mode 100644
index 000000000..ab8b0c7cf
--- /dev/null
+++ b/packages/api/test/guide-registry-loader.test.js
@@ -0,0 +1,102 @@
+import assert from 'node:assert/strict';
+import { rmSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { describe, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+describe('F155 guide registry loader target validation', async () => {
+ const { getRegistryEntries, getValidGuideIds, isValidGuideTarget, loadGuideFlow, resolveGuideForIntent } =
+ await import('../dist/domains/guides/guide-registry-loader.js');
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
+
+ test('accepts registry-safe target ids', () => {
+ assert.equal(isValidGuideTarget('hub.trigger'), true);
+ assert.equal(isValidGuideTarget('cats.add-member'), true);
+ assert.equal(isValidGuideTarget('members.row_new-confirm'), true);
+ });
+
+ test('rejects selector-breaking target ids', () => {
+ assert.equal(isValidGuideTarget('bad"]div'), false);
+ assert.equal(isValidGuideTarget('bad target'), false);
+ assert.equal(isValidGuideTarget('bad>target'), false);
+ });
+
+ test('loaded add-member flow contains only validated targets', () => {
+ const flow = loadGuideFlow('add-member');
+ for (const step of flow.steps) {
+ assert.equal(isValidGuideTarget(step.target), true, `step ${step.id} target should be valid`);
+ }
+ });
+
+ test('loaded add-member flow waits for member profile save before completion', () => {
+ const flow = loadGuideFlow('add-member');
+ const createIndex = flow.steps.findIndex((step) => step.id === 'click-add-member');
+ const editIndex = flow.steps.findIndex((step) => step.id === 'edit-member-profile');
+ const editStep = flow.steps[editIndex];
+
+ assert.ok(createIndex >= 0, 'create step should exist');
+ assert.ok(editIndex > createIndex, 'edit step should happen after member creation');
+ assert.ok(editStep, 'edit-member-profile step should exist');
+ assert.equal(editStep.target, 'member-editor.profile');
+ assert.equal(editStep.advance, 'confirm');
+ });
+
+ test('matches meaningful partial queries without requiring full keyword', () => {
+ const matches = resolveGuideForIntent('添加');
+ assert.equal(matches[0]?.id, 'add-member');
+ });
+
+ test('does not offer guides for single-character queries', () => {
+ const matches = resolveGuideForIntent('添');
+ assert.equal(matches.length, 0);
+ });
+
+ test('matches exact keyword queries regardless of reverse-match threshold', () => {
+ const matches = resolveGuideForIntent('加成员');
+ assert.equal(matches[0]?.id, 'add-member');
+ });
+
+ test('rejects a flow file whose internal id does not match the requested guide id', () => {
+ const guideId = 'test-mismatched-flow-id';
+ const flowPath = resolve(repoRoot, 'guides', 'flows', `${guideId}.yaml`);
+ const entry = {
+ id: guideId,
+ name: 'Test mismatched flow',
+ description: 'Regression fixture for mismatched flow ids',
+ flow_file: `flows/${guideId}.yaml`,
+ keywords: ['mismatched flow id'],
+ category: 'test',
+ priority: 'P0',
+ cross_system: false,
+ estimated_time: '1min',
+ };
+
+ writeFileSync(
+ flowPath,
+ [
+ 'id: wrong-flow-id',
+ 'name: Wrong Flow',
+ 'steps:',
+ ' - id: step-1',
+ ' target: hub.trigger',
+ ' tips: Open hub',
+ ' advance: click',
+ '',
+ ].join('\n'),
+ 'utf8',
+ );
+ getRegistryEntries().push(entry);
+ getValidGuideIds().add(guideId);
+
+ try {
+ assert.throws(
+ () => loadGuideFlow(guideId),
+ /Invalid flow file for "test-mismatched-flow-id": expected id "test-mismatched-flow-id"/,
+ );
+ } finally {
+ getRegistryEntries().pop();
+ getValidGuideIds().delete(guideId);
+ rmSync(flowPath, { force: true });
+ }
+ });
+});
diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js
index 0827dde74..67b6e612f 100644
--- a/packages/api/test/invoke-single-cat.test.js
+++ b/packages/api/test/invoke-single-cat.test.js
@@ -2672,6 +2672,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
it('F127 P1: falls back to CAT_TEMPLATE_PATH project when thread projectPath is absent', async () => {
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const templateRoot = await mkdtemp(join(tmpdir(), 'f127-active-template-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = templateRoot;
await writeFile(join(templateRoot, 'cat-template.json'), '{}', 'utf-8');
const boundProfile = await createProviderProfile(templateRoot, {
provider: 'openai',
@@ -2977,6 +2978,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
it('F127 P1: prefers member-bound openai profile over protocol active profile', async () => {
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const root = await mkdtemp(join(tmpdir(), 'f127-openai-profile-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root;
const apiDir = join(root, 'packages', 'api');
await mkdir(apiDir, { recursive: true });
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8');
@@ -3262,6 +3264,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
it('F127: ignores legacy api_key protocol metadata when the member explicitly selected the client', async () => {
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const root = await mkdtemp(join(tmpdir(), 'f127-bound-mismatch-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root;
const apiDir = join(root, 'packages', 'api');
await mkdir(apiDir, { recursive: true });
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8');
@@ -3399,6 +3402,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
it('F127: injects OPENROUTER_API_KEY for opencode members bound to openai api_key profiles', async () => {
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const root = await mkdtemp(join(tmpdir(), 'f127-openrouter-key-injection-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root;
const apiDir = join(root, 'packages', 'api');
await mkdir(apiDir, { recursive: true });
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8');
@@ -3471,6 +3475,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
mod._resetOpenCodeKnownModels(new Set(['anthropic/claude-opus-4-6']));
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const root = await mkdtemp(join(tmpdir(), 'f189-opencode-custom-provider-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root;
const apiDir = join(root, 'packages', 'api');
await mkdir(apiDir, { recursive: true });
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8');
@@ -3551,6 +3556,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
it('F189: bare model + provider assembles composite model for custom provider routing', async () => {
const { createProviderProfile } = await import('./helpers/create-test-account.js');
const root = await mkdtemp(join(tmpdir(), 'f189-oc-bare-model-'));
+ process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root;
const apiDir = join(root, 'packages', 'api');
await mkdir(apiDir, { recursive: true });
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8');
@@ -4518,3 +4524,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => {
}
});
});
+
+// F155: Old pre-invocation guide routing hook tests removed.
+// Guide matching now happens at routing layer (route-serial/route-parallel)
+// and is injected via SystemPromptBuilder + guide-interaction skill.
+// New tests for the routing-layer matching should be added separately.
diff --git a/packages/api/test/route-strategies.test.js b/packages/api/test/route-strategies.test.js
index 1a55644fa..c1581361f 100644
--- a/packages/api/test/route-strategies.test.js
+++ b/packages/api/test/route-strategies.test.js
@@ -56,6 +56,62 @@ function createSequentialCapturingService(catId, responses) {
};
}
+function createGuideAckThreadStore(initialGuideState, currentGuideState, projectPath = '/tmp/test') {
+ let getCount = 0;
+ const updates = [];
+ return {
+ updates,
+ async get() {
+ getCount += 1;
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath,
+ guideState: getCount === 1 ? initialGuideState : currentGuideState,
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async updateGuideState(threadId, guideState) {
+ updates.push({ threadId, guideState });
+ },
+ };
+}
+
+function createSharedDefaultGuideThreadStore(guideState) {
+ const updates = [];
+ return {
+ updates,
+ async get() {
+ return {
+ id: 'default',
+ title: 'Default Thread',
+ createdBy: 'system',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState,
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async consumeMentionRoutingFeedback() {
+ return null;
+ },
+ async updateParticipantActivity() {},
+ async updateGuideState(threadId, nextGuideState) {
+ updates.push({ threadId, guideState: nextGuideState });
+ },
+ };
+}
+
function createMockDeps(services, appendCalls, threadStore = null) {
let counter = 0;
return {
@@ -759,6 +815,622 @@ describe('routeParallel abort marks healthy (#267)', () => {
});
});
+describe('F155 guide offer ownership', () => {
+ it('serial: suppresses fresh guide offers when another user has a non-terminal guide on shared default thread', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const codexService = createCapturingService('codex', '我来处理这个请求');
+ const threadStore = createSharedDefaultGuideThreadStore({
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'active',
+ offeredAt: Date.now(),
+ startedAt: Date.now(),
+ offeredBy: 'opus',
+ userId: 'other-user',
+ });
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) {
+ }
+
+ assert.ok(
+ !codexService.calls[0].includes('Guide Matched:'),
+ 'foreign non-terminal guide must block fresh guide matching for another user',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'routing must not emit a fresh offered guide when another user already owns the active guide',
+ );
+ assert.equal(threadStore.updates.length, 0, 'blocked guide state must not be mutated by the wrong user');
+ });
+
+ it('serial: ignores another user guide state on shared default thread', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const codexService = createCapturingService('codex', '我来处理这个请求');
+ const threadStore = createSharedDefaultGuideThreadStore({
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ userId: 'other-user',
+ });
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) {
+ }
+
+ assert.ok(
+ codexService.calls[0].includes('Guide Matched:'),
+ 'foreign guide state should be hidden so current user can receive a fresh guide offer',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('Guide Completed:'),
+ 'foreign completed guide must not leak into the current user prompt',
+ );
+ assert.equal(threadStore.updates.length, 0, 'hidden foreign guide must not be acked by the wrong user');
+ });
+
+ it('serial: injects offered guide only to the first target cat', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const opusService = createCapturingService('opus', '我来处理引导');
+ const codexService = createCapturingService('codex', '不该收到引导 offer');
+ const deps = createMockDeps({ opus: opusService, codex: codexService });
+
+ for await (const _ of routeSerial(deps, ['opus', 'codex'], '请帮我添加成员', 'user1', 'thread1')) {
+ }
+
+ assert.equal(opusService.calls.length, 1, 'first cat should be invoked');
+ assert.equal(codexService.calls.length, 1, 'second cat should still be invoked');
+ assert.ok(opusService.calls[0].includes('status="offered"'), 'first cat should receive guide offer instructions');
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'second cat must not receive duplicate guide offer instructions',
+ );
+ });
+
+ it('serial: passes guide selection context to a non-owner target cat', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const codexService = createCapturingService('codex', '我来给步骤概览');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: Date.now(),
+ offeredBy: 'opus',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async consumeMentionRoutingFeedback() {
+ return null;
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['codex'], '引导流程:步骤概览', 'user1', 'thread1')) {
+ }
+
+ assert.equal(codexService.calls.length, 1, 'non-owner target cat should still be invoked');
+ assert.ok(
+ codexService.calls[0].includes('用户选择了「步骤概览」'),
+ 'selected guide branch must be visible to the routed cat even when it did not offer the guide',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'selection follow-up must not regress into a duplicate offered prompt',
+ );
+ });
+
+ it('serial: routes owner-missing guide selection to only the first target cat', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const opusService = createCapturingService('opus', '我来给步骤概览');
+ const codexService = createCapturingService('codex', '我不该收到选择分支');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: Date.now(),
+ offeredBy: 'dare',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async consumeMentionRoutingFeedback() {
+ return null;
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['opus', 'codex'], '引导流程:步骤概览', 'user1', 'thread1')) {
+ }
+
+ assert.ok(
+ opusService.calls[0].includes('用户选择了「步骤概览」'),
+ 'first target cat should receive selection fallback',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('用户选择了「步骤概览」'),
+ 'second target cat must not receive duplicate selection fallback',
+ );
+ });
+
+ it('serial: routes owner-missing awaiting_choice guide to only the first target cat', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const opusService = createCapturingService('opus', '我来处理等待中的引导');
+ const codexService = createCapturingService('codex', '我不该收到 pending guide');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'awaiting_choice',
+ offeredAt: Date.now(),
+ offeredBy: 'dare',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async consumeMentionRoutingFeedback() {
+ return null;
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.ok(
+ opusService.calls[0].includes('Guide Pending:'),
+ 'first target cat should receive the awaiting_choice reminder fallback',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('Guide Pending:'),
+ 'second target cat must not receive duplicate awaiting_choice context',
+ );
+ });
+
+ it('parallel: passes guide selection context to a non-owner target cat', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const codexService = createCapturingService('codex', '我来给步骤概览');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: Date.now(),
+ offeredBy: 'opus',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['codex'], '引导流程:步骤概览', 'user1', 'thread1')) {
+ }
+
+ assert.equal(codexService.calls.length, 1, 'parallel non-owner target cat should still be invoked');
+ assert.ok(
+ codexService.calls[0].includes('用户选择了「步骤概览」'),
+ 'parallel routed cat must see the selected guide context when the offer owner is absent',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'parallel selection follow-up must not regress into a duplicate offered prompt',
+ );
+ });
+
+ it('parallel: routes owner-missing guide selection to only the first target cat', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const opusService = createCapturingService('opus', '我来给步骤概览');
+ const codexService = createCapturingService('codex', '我不该收到选择分支');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'offered',
+ offeredAt: Date.now(),
+ offeredBy: 'dare',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['opus', 'codex'], '引导流程:步骤概览', 'user1', 'thread1')) {
+ }
+
+ assert.ok(
+ opusService.calls[0].includes('用户选择了「步骤概览」'),
+ 'first target cat should receive selection fallback',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('用户选择了「步骤概览」'),
+ 'second target cat must not receive duplicate selection fallback',
+ );
+ });
+
+ it('parallel: routes owner-missing awaiting_choice guide to only the first target cat', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const opusService = createCapturingService('opus', '我来处理等待中的引导');
+ const codexService = createCapturingService('codex', '我不该收到 pending guide');
+ const threadStore = {
+ async get() {
+ return {
+ id: 'thread1',
+ title: 'Test',
+ createdBy: 'user1',
+ participants: [],
+ lastActiveAt: Date.now(),
+ createdAt: Date.now(),
+ projectPath: 'default',
+ guideState: {
+ v: 1,
+ guideId: 'add-member',
+ status: 'awaiting_choice',
+ offeredAt: Date.now(),
+ offeredBy: 'dare',
+ },
+ };
+ },
+ async getParticipantsWithActivity() {
+ return [];
+ },
+ async updateParticipantActivity() {},
+ };
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.ok(
+ opusService.calls[0].includes('Guide Pending:'),
+ 'first target cat should receive the awaiting_choice reminder fallback',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('Guide Pending:'),
+ 'second target cat must not receive duplicate awaiting_choice context',
+ );
+ });
+
+ it('parallel: injects offered guide only to the first target cat', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const opusService = createCapturingService('opus', '我来处理引导');
+ const codexService = createCapturingService('codex', '不该收到引导 offer');
+ const deps = createMockDeps({ opus: opusService, codex: codexService });
+
+ for await (const _ of routeParallel(deps, ['opus', 'codex'], '请帮我添加成员', 'user1', 'thread1')) {
+ }
+
+ assert.equal(opusService.calls.length, 1, 'first cat should be invoked');
+ assert.equal(codexService.calls.length, 1, 'second cat should still be invoked');
+ assert.ok(opusService.calls[0].includes('status="offered"'), 'first cat should receive guide offer instructions');
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'second cat must not receive duplicate guide offer instructions',
+ );
+ });
+
+ it('parallel: suppresses fresh guide offers when another user has a non-terminal guide on shared default thread', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const codexService = createCapturingService('codex', '我来处理这个请求');
+ const threadStore = createSharedDefaultGuideThreadStore({
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'active',
+ offeredAt: Date.now(),
+ startedAt: Date.now(),
+ offeredBy: 'opus',
+ userId: 'other-user',
+ });
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) {
+ }
+
+ assert.ok(
+ !codexService.calls[0].includes('Guide Matched:'),
+ 'foreign non-terminal guide must block fresh guide matching for another user',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('status="offered"'),
+ 'parallel routing must not emit a fresh offered guide when another user already owns the active guide',
+ );
+ assert.equal(threadStore.updates.length, 0, 'blocked guide state must not be mutated by the wrong user');
+ });
+
+ it('parallel: ignores another user guide state on shared default thread', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const codexService = createCapturingService('codex', '我来处理这个请求');
+ const threadStore = createSharedDefaultGuideThreadStore({
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ userId: 'other-user',
+ });
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) {
+ }
+
+ assert.ok(
+ codexService.calls[0].includes('Guide Matched:'),
+ 'foreign guide state should be hidden so current user can receive a fresh guide offer',
+ );
+ assert.ok(
+ !codexService.calls[0].includes('Guide Completed:'),
+ 'foreign completed guide must not leak into the current user prompt',
+ );
+ assert.equal(threadStore.updates.length, 0, 'hidden foreign guide must not be acked by the wrong user');
+ });
+});
+
+describe('F155 guide completion ack ownership', () => {
+ it('serial: does not ack a different guide that replaced the completed one', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const threadStore = createGuideAckThreadStore(
+ {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ },
+ {
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'offered',
+ offeredAt: Date.now(),
+ offeredBy: 'codex',
+ },
+ );
+ const deps = createMockDeps({ opus: createMockService('opus', 'done') }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['opus'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(threadStore.updates.length, 0, 'must not ack a replacement guide');
+ });
+
+ it('parallel: does not ack a different guide that replaced the completed one', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const threadStore = createGuideAckThreadStore(
+ {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ },
+ {
+ v: 1,
+ guideId: 'configure-provider',
+ status: 'active',
+ offeredAt: Date.now(),
+ startedAt: Date.now(),
+ offeredBy: 'codex',
+ },
+ );
+ const deps = createMockDeps({ opus: createMockService('opus', 'done') }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['opus'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(threadStore.updates.length, 0, 'must not ack a replacement guide');
+ });
+
+ it('serial: does not ack completed guide after a silent done-only turn', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'codex',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const deps = createMockDeps({ codex: createDoneOnlyService('codex') }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(threadStore.updates.length, 0, 'silent done-only turn must not ack guide completion');
+ });
+
+ it('parallel: does not ack completed guide after a silent done-only turn', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'codex',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const deps = createMockDeps({ codex: createDoneOnlyService('codex') }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(threadStore.updates.length, 0, 'silent done-only turn must not ack guide completion');
+ });
+
+ it('serial: injects and acks completed guide when owner cat is not routed', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const codexService = createCapturingService('codex', '好的,我继续帮你');
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(codexService.calls.length, 1, 'routed non-owner cat should still be invoked');
+ assert.ok(
+ codexService.calls[0].includes('Guide Completed:'),
+ 'routed non-owner cat must see completed guide context when owner is absent',
+ );
+ assert.equal(threadStore.updates.length, 1, 'visible non-owner response should ack guide completion');
+ assert.equal(threadStore.updates[0].guideState.completionAcked, true);
+ });
+
+ it('serial: routes completed-guide fallback only to the first target cat', async () => {
+ const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'dare',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const opusService = createCapturingService('opus', '我来接着处理');
+ const codexService = createCapturingService('codex', '我也看到了');
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeSerial(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.ok(opusService.calls[0].includes('Guide Completed:'), 'first target cat should receive completed guide');
+ assert.ok(
+ !codexService.calls[0].includes('Guide Completed:'),
+ 'second target cat must not receive duplicate completed guide fallback',
+ );
+ assert.equal(threadStore.updates.length, 1, 'only one routed cat should ack the completed guide');
+ });
+
+ it('parallel: injects and acks completed guide when owner cat is not routed', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'opus',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const codexService = createCapturingService('codex', '好的,我继续帮你');
+ const deps = createMockDeps({ codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.equal(codexService.calls.length, 1, 'routed non-owner cat should still be invoked');
+ assert.ok(
+ codexService.calls[0].includes('Guide Completed:'),
+ 'routed non-owner cat must see completed guide context when owner is absent',
+ );
+ assert.equal(threadStore.updates.length, 1, 'visible non-owner response should ack guide completion');
+ assert.equal(threadStore.updates[0].guideState.completionAcked, true);
+ });
+
+ it('parallel: routes completed-guide fallback only to the first target cat', async () => {
+ const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+ const completedGuide = {
+ v: 1,
+ guideId: 'add-member',
+ status: 'completed',
+ offeredAt: Date.now(),
+ completedAt: Date.now(),
+ offeredBy: 'dare',
+ };
+ const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default');
+ const opusService = createCapturingService('opus', '我来接着处理');
+ const codexService = createCapturingService('codex', '我也看到了');
+ const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore);
+
+ for await (const _ of routeParallel(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) {
+ }
+
+ assert.ok(opusService.calls[0].includes('Guide Completed:'), 'first target cat should receive completed guide');
+ assert.ok(
+ !codexService.calls[0].includes('Guide Completed:'),
+ 'second target cat must not receive duplicate completed guide fallback',
+ );
+ assert.equal(threadStore.updates.length, 1, 'only one routed cat should ack the completed guide');
+ });
+});
+
describe('routeParallel whisper privacy (F35)', () => {
it('does NOT inject whisper content for non-recipient cat in parallel mode', async () => {
const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
diff --git a/packages/api/test/session-bind.test.js b/packages/api/test/session-bind.test.js
index 9dffa17bb..bd23bcb84 100644
--- a/packages/api/test/session-bind.test.js
+++ b/packages/api/test/session-bind.test.js
@@ -275,6 +275,49 @@ describe('Session bind API route', () => {
}
});
+ test('returns 403 for non-default system-owned thread', async () => {
+ const { app, threadStore } = await buildApp();
+ try {
+ const thread = await threadStore.create('system', 'System Thread');
+
+ const res = await app.inject({
+ method: 'PATCH',
+ url: `/api/threads/${thread.id}/sessions/opus/bind`,
+ headers: { 'x-cat-cafe-user': 'other-user' },
+ payload: { cliSessionId: 'cli-system' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ } finally {
+ await app.close();
+ }
+ });
+
+ test('returns 403 when default-thread active session belongs to another user', async () => {
+ const { app, threadStore, sessionChainStore } = await buildApp();
+ try {
+ const thread = await threadStore.get('default');
+
+ sessionChainStore.create({
+ cliSessionId: 'cli-owner',
+ threadId: thread.id,
+ catId: 'opus',
+ userId: 'owner-user',
+ });
+
+ const res = await app.inject({
+ method: 'PATCH',
+ url: `/api/threads/${thread.id}/sessions/opus/bind`,
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ payload: { cliSessionId: 'cli-evil' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ } finally {
+ await app.close();
+ }
+ });
+
test('supports all valid catIds', async () => {
const { app, threadStore } = await buildApp();
try {
diff --git a/packages/api/test/session-chain-route.test.js b/packages/api/test/session-chain-route.test.js
index 9e2ca2237..8a8bfb756 100644
--- a/packages/api/test/session-chain-route.test.js
+++ b/packages/api/test/session-chain-route.test.js
@@ -91,6 +91,104 @@ describe('Session Chain Routes', () => {
assert.equal(res.statusCode, 403);
});
+ it('GET /api/threads/default/sessions allows system-owned default thread', async () => {
+ const store = await setup(
+ mockThreadStore({
+ default: { id: 'default', createdBy: 'system' },
+ }),
+ );
+ store.create({ cliSessionId: 'cli-default-1', threadId: 'default', catId: 'opus', userId: 'default-user' });
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/threads/default/sessions',
+ headers: { 'x-cat-cafe-user': 'default-user' },
+ });
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.sessions.length, 1);
+ });
+
+ it('GET /api/threads/default/sessions filters default-thread sessions to the caller', async () => {
+ const store = await setup(
+ mockThreadStore({
+ default: { id: 'default', createdBy: 'system' },
+ }),
+ );
+ store.create({ cliSessionId: 'cli-default-owner', threadId: 'default', catId: 'opus', userId: 'owner-user' });
+ store.create({ cliSessionId: 'cli-default-other', threadId: 'default', catId: 'codex', userId: 'other-user' });
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/threads/default/sessions',
+ headers: { 'x-cat-cafe-user': 'owner-user' },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.sessions.length, 1);
+ assert.equal(body.sessions[0].userId, 'owner-user');
+ });
+
+ it('GET /api/sessions/:sessionId allows records under system-owned default thread', async () => {
+ const store = await setup(
+ mockThreadStore({
+ default: { id: 'default', createdBy: 'system' },
+ }),
+ );
+ const record = store.create({
+ cliSessionId: 'cli-default-2',
+ threadId: 'default',
+ catId: 'opus',
+ userId: 'default-user',
+ });
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/sessions/${record.id}`,
+ headers: { 'x-cat-cafe-user': 'default-user' },
+ });
+ assert.equal(res.statusCode, 200);
+ });
+
+ it('GET /api/sessions/:sessionId rejects other users on system-owned default thread', async () => {
+ const store = await setup(
+ mockThreadStore({
+ default: { id: 'default', createdBy: 'system' },
+ }),
+ );
+ const record = store.create({
+ cliSessionId: 'cli-default-owner',
+ threadId: 'default',
+ catId: 'opus',
+ userId: 'owner-user',
+ });
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/sessions/${record.id}`,
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ });
+
+ it('GET /api/threads/:threadId/sessions rejects system-owned non-default threads', async () => {
+ await setup(
+ mockThreadStore({
+ 'system-thread': { id: 'system-thread', createdBy: 'system' },
+ }),
+ );
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/threads/system-thread/sessions',
+ headers: { 'x-cat-cafe-user': 'other-user' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ });
+
// --- Normal happy-path tests (with identity) ---
it('GET /api/threads/:threadId/sessions returns empty array for unknown thread', async () => {
@@ -311,4 +409,55 @@ describe('Session Chain Routes', () => {
const body = JSON.parse(res.payload);
assert.equal(body.activeSessionId, active.id);
});
+
+ it('POST /api/sessions/:sessionId/unseal rejects system-owned non-default threads', async () => {
+ const store = await setup(
+ mockThreadStore({
+ 'system-thread': { id: 'system-thread', createdBy: 'system' },
+ }),
+ );
+ const sealed = store.create({
+ cliSessionId: 'cli-system-thread',
+ threadId: 'system-thread',
+ catId: 'opus',
+ userId: 'system',
+ });
+ store.update(sealed.id, { status: 'sealed', sealReason: 'threshold', sealedAt: Date.now(), updatedAt: Date.now() });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/sessions/${sealed.id}/unseal`,
+ headers: { 'x-cat-cafe-user': 'other-user' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ });
+
+ it('POST /api/sessions/:sessionId/unseal rejects other users on system-owned default thread', async () => {
+ const store = await setup(
+ mockThreadStore({
+ default: { id: 'default', createdBy: 'system' },
+ }),
+ );
+ const sealed = store.create({
+ cliSessionId: 'cli-default-sealed',
+ threadId: 'default',
+ catId: 'opus',
+ userId: 'owner-user',
+ });
+ store.update(sealed.id, {
+ status: 'sealed',
+ sealReason: 'threshold',
+ sealedAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/sessions/${sealed.id}/unseal`,
+ headers: { 'x-cat-cafe-user': 'attacker-user' },
+ });
+
+ assert.equal(res.statusCode, 403);
+ });
});
diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js
index 936580030..7d7a49eea 100644
--- a/packages/api/test/system-prompt-builder.test.js
+++ b/packages/api/test/system-prompt-builder.test.js
@@ -862,6 +862,98 @@ describe('SystemPromptBuilder', () => {
assert.ok(ctx.includes('F073'), 'Should contain feature ID');
});
+ test('guide prompt emits offered transition only for a brand-new guide match', async () => {
+ const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js');
+ const ctx = buildInvocationContext({
+ catId: 'opus',
+ mode: 'independent',
+ teammates: [],
+ mcpAvailable: false,
+ threadId: 'thread-guide',
+ guideCandidate: {
+ id: 'add-member',
+ name: '添加成员',
+ estimatedTime: '3min',
+ status: 'offered',
+ isNewOffer: true,
+ },
+ });
+
+ assert.ok(ctx.includes('Guide Matched'), 'new match should emit offer card instructions');
+ assert.ok(ctx.includes('status="offered"'), 'new match should persist offered transition exactly once');
+ });
+
+ test('guide prompt does not re-send offered transition after the guide is already offered', async () => {
+ const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js');
+ const ctx = buildInvocationContext({
+ catId: 'opus',
+ mode: 'independent',
+ teammates: [],
+ mcpAvailable: false,
+ threadId: 'thread-guide',
+ guideCandidate: {
+ id: 'add-member',
+ name: '添加成员',
+ estimatedTime: '3min',
+ status: 'offered',
+ isNewOffer: false,
+ },
+ });
+
+ assert.ok(ctx.includes('Guide Pending'), 'existing offered guide should become a stable pending reminder');
+ assert.ok(!ctx.includes('status="offered"'), 'existing offered guide must not re-send offered transition');
+ assert.ok(!ctx.includes('cat_cafe_create_rich_block'), 'existing offered guide must not repeat the offer card');
+ });
+
+ test('guide preview from offered state advances to awaiting_choice once', async () => {
+ const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js');
+ const ctx = buildInvocationContext({
+ catId: 'opus',
+ mode: 'independent',
+ teammates: [],
+ mcpAvailable: false,
+ threadId: 'thread-guide',
+ guideCandidate: {
+ id: 'add-member',
+ name: '添加成员',
+ estimatedTime: '3min',
+ status: 'offered',
+ userSelection: '步骤概览',
+ },
+ });
+
+ assert.ok(ctx.includes('Guide Selection'), 'preview branch should still activate from offered state');
+ assert.ok(
+ ctx.includes('status="awaiting_choice"'),
+ 'first preview should advance the guide to awaiting_choice before resolving steps',
+ );
+ });
+
+ test('guide preview from awaiting_choice does not re-send awaiting_choice transition', async () => {
+ const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js');
+ const ctx = buildInvocationContext({
+ catId: 'opus',
+ mode: 'independent',
+ teammates: [],
+ mcpAvailable: false,
+ threadId: 'thread-guide',
+ guideCandidate: {
+ id: 'add-member',
+ name: '添加成员',
+ estimatedTime: '3min',
+ status: 'awaiting_choice',
+ userSelection: '步骤概览',
+ },
+ });
+
+ assert.ok(ctx.includes('Guide Selection'), 'preview branch should remain available after awaiting_choice');
+ assert.ok(ctx.includes('cat_cafe_guide_resolve'), 'repeated preview should still resolve and summarize steps');
+ assert.ok(
+ !ctx.includes('status="awaiting_choice"'),
+ 'repeated preview must not emit an awaiting_choice -> awaiting_choice self-transition',
+ );
+ });
+
test('buildInvocationContext omits SOP hint when sopStageHint absent', async () => {
const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js');
const ctx = buildInvocationContext({
diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts
index c7728c3e9..b23bc4990 100644
--- a/packages/mcp-server/src/tools/callback-tools.ts
+++ b/packages/mcp-server/src/tools/callback-tools.ts
@@ -722,6 +722,42 @@ export async function handleGetThreadCats(): Promise {
return callbackGet('/api/callbacks/thread-cats');
}
+// F155: Guide Engine
+
+export const updateGuideStateInputSchema = {
+ threadId: z.string().min(1).describe('Thread ID where the guide is being offered/active'),
+ guideId: z.string().min(1).describe('Guide ID (e.g. "add-member")'),
+ status: z
+ .enum(['offered', 'awaiting_choice', 'active', 'completed', 'cancelled'])
+ .describe(
+ 'Target guide status. Valid transitions: offered→awaiting_choice/active/cancelled, awaiting_choice→active/cancelled, active→completed/cancelled',
+ ),
+ currentStep: z.number().int().min(0).optional().describe('Current step index (only when status=active)'),
+};
+
+export async function handleUpdateGuideState(input: {
+ threadId: string;
+ guideId: string;
+ status: string;
+ currentStep?: number | undefined;
+}): Promise {
+ const body: Record = { threadId: input.threadId, guideId: input.guideId, status: input.status };
+ if (input.currentStep !== undefined) body['currentStep'] = input.currentStep;
+ return callbackPost('/api/callbacks/update-guide-state', body);
+}
+
+export async function handleStartGuide(input: { guideId: string }): Promise {
+ return callbackPost('/api/callbacks/start-guide', { guideId: input.guideId });
+}
+
+export async function handleGuideResolve(input: { intent: string }): Promise {
+ return callbackPost('/api/callbacks/guide-resolve', { intent: input.intent });
+}
+
+export async function handleGuideControl(input: { action: string }): Promise {
+ return callbackPost('/api/callbacks/guide-control', { action: input.action });
+}
+
export const callbackTools = [
{
name: 'cat_cafe_post_message',
@@ -919,4 +955,50 @@ export const callbackTools = [
inputSchema: bootcampEnvCheckInputSchema,
handler: handleBootcampEnvCheck,
},
+ // ============ F155: Guide Engine ============
+ {
+ name: 'cat_cafe_update_guide_state',
+ description:
+ 'Update the guide session state for a thread. Must be called to persist state transitions. ' +
+ 'First call creates state (status must be "offered"). Subsequent calls must follow valid non-start transitions: ' +
+ 'offered→awaiting_choice/cancelled, awaiting_choice→cancelled, active→completed/cancelled. ' +
+ 'Do not use this tool to enter "active" — call cat_cafe_start_guide for offered/awaiting_choice→active so frontend start side effects run. ' +
+ 'One active guide per thread — complete or cancel before offering a new one.',
+ inputSchema: updateGuideStateInputSchema,
+ handler: handleUpdateGuideState,
+ },
+ {
+ name: 'cat_cafe_guide_resolve',
+ description:
+ 'Match user intent to available guided flows. ' +
+ 'Call this when a user asks how to do something (e.g. "怎么添加成员", "how to add a member"). ' +
+ 'Returns a ranked list of matching guide flows with IDs, names, and descriptions. ' +
+ 'If matches are found, suggest the top match to the user and ask if they want to start the guide. ' +
+ 'On confirmation, call cat_cafe_start_guide with the matched guideId.',
+ inputSchema: {
+ intent: z.string().min(1).describe('User intent text (e.g. "添加成员", "配置飞书")'),
+ },
+ handler: handleGuideResolve,
+ },
+ {
+ name: 'cat_cafe_start_guide',
+ description:
+ 'Start an interactive guided flow on the Console frontend. ' +
+ 'Requires the guide to be in "offered" or "awaiting_choice" state (call cat_cafe_update_guide_state first). ' +
+ 'Transitions guide to "active" and emits socket event for frontend overlay.',
+ inputSchema: {
+ guideId: z.string().min(1).describe('Guide flow ID (e.g. "add-member")'),
+ },
+ handler: handleStartGuide,
+ },
+ {
+ name: 'cat_cafe_guide_control',
+ description:
+ 'Control an active guide session. Requires guide to be in "active" state. ' +
+ 'Actions: "next" (advance), "back" (previous step), "skip" (skip step), "exit" (cancel guide).',
+ inputSchema: {
+ action: z.enum(['next', 'back', 'skip', 'exit']).describe('Guide control action'),
+ },
+ handler: handleGuideControl,
+ },
] as const;
diff --git a/packages/shared/src/types/rich.ts b/packages/shared/src/types/rich.ts
index e0bc0a6ae..329edf9e0 100644
--- a/packages/shared/src/types/rich.ts
+++ b/packages/shared/src/types/rich.ts
@@ -84,6 +84,16 @@ export interface RichAudioBlock extends RichBlockBase {
mimeType?: string;
}
+/** F155: Direct action for interactive options that bypass the chat message pipeline */
+export interface OptionAction {
+ /** Action type — 'callback' calls an API endpoint directly from the frontend */
+ type: 'callback';
+ /** API endpoint path (e.g. '/api/guide-actions/start') */
+ endpoint: string;
+ /** Payload sent as JSON body to the endpoint */
+ payload?: Record;
+}
+
/** F096: Interactive rich block — user can select/confirm within the block */
export interface InteractiveOption {
id: string;
@@ -98,6 +108,8 @@ export interface InteractiveOption {
customInput?: boolean;
/** Placeholder text for the custom input field */
customInputPlaceholder?: string;
+ /** F155: When present, clicking this option calls the endpoint directly instead of sending a chat message */
+ action?: OptionAction;
}
export interface RichInteractiveBlock extends RichBlockBase {
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index e9cac40d5..cfe78a12b 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -154,6 +154,24 @@
--text-secondary: var(--cafe-text-secondary);
--border-radius-base: 16px;
--spacing-unit: 8px;
+
+ /* F155: Guide Engine Tokens */
+ --guide-overlay-bg: rgba(12, 16, 24, 0.62);
+ --guide-cutout-ring: #d4853a;
+ --guide-cutout-shadow: rgba(212, 133, 58, 0.35);
+ --guide-hud-bg: #fffdf8;
+ --guide-hud-border: #e7dac7;
+ --guide-text-primary: #2b251f;
+ --guide-text-secondary: #6f6257;
+ --guide-success: #2f9e44;
+ --guide-error: #d94848;
+ --guide-z-overlay: 1100;
+ --guide-z-hud: 1110;
+ --guide-z-pulse: 1120;
+ --guide-radius: 14px;
+ --guide-gap: 12px;
+ --guide-motion-fast: 160ms;
+ --guide-motion-normal: 260ms;
}
/* --- Dark Mode: override semantic tokens only --- */
@@ -322,6 +340,67 @@ body {
animation: pulse-subtle 2s ease-in-out infinite;
}
+/* F155: Guide Engine Animations */
+@keyframes guide-breathe {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.03);
+ opacity: 0.85;
+ }
+}
+
+@keyframes guide-hud-enter {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes guide-success-flash {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(0.9);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes guide-error-shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 25% {
+ transform: translateX(-3px);
+ }
+ 75% {
+ transform: translateX(3px);
+ }
+}
+
+.animate-guide-hud-enter {
+ animation: guide-hud-enter 160ms ease-out;
+}
+
+.animate-guide-success {
+ animation: guide-success-flash 0.4s ease-out;
+}
+
+.animate-guide-error {
+ animation: guide-error-shake 0.3s ease-out;
+}
+
@keyframes shake {
0%,
100% {
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 8e0542490..4340b0b36 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from 'next';
import { BrakeModal } from '@/components/BrakeModal';
+import { GuideOverlay } from '@/components/GuideOverlay';
import { SessionBootstrap } from '@/components/SessionBootstrap';
import { ThemeProvider } from '@/components/ThemeProvider';
import { ToastContainer } from '@/components/ToastContainer';
@@ -40,6 +41,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+