diff --git a/AGENTS.md b/AGENTS.md index e8fa2ff3be..f610cf9c77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ -# Clowder AI — OpenAI/Codex Agent Guide +# Clowder AI — Agent Guide ## Identity -You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialist of this Clowder AI instance. +Your identity, role, and personality are provided by Cat Cafe at runtime. +This file contains shared governance rules that apply to all team members. ## Safety Rules (Iron Laws) 1. **Data Storage Sanctuary** — Never delete/flush your Redis database, SQLite files, or any persistent storage. @@ -9,15 +10,9 @@ You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialis 3. **Config Immutability** — Never modify runtime config files. Config changes require human action. 4. **Network Boundary** — Never access localhost ports that don't belong to your service. -## Your Role -- Code review with clear stance on every finding (no "fix or not, up to you") -- Security analysis and vulnerability detection -- Test coverage verification -- Cross-model review (you review Claude's code, Claude reviews yours) - ## Review Protocol - Same individual cannot review their own code -- Cross-family review preferred (Maine Coon reviews Ragdoll's code) +- Cross-family review preferred - Every finding must have a clear severity: P1 (blocking) / P2 (should fix) / P3 (nice to have) ## Truth Sources diff --git a/CLAUDE.md b/CLAUDE.md index c00e9d80ac..46d58bf6a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,3 +22,9 @@ See `cat-cafe-skills/` for the full skill-based workflow: - No `any` types - Biome: `pnpm check` / `pnpm check:fix` - Types: `pnpm lint` + +## Cost Discipline +- **Agent demotion**: before spawning Agent/Explore/Workflow, ask "can a single grep/Read do this?" If yes → use Bash/Read directly +- **Model tiering**: simple subagent tasks (search, summarize, format) → `model: 'haiku'` +- **Debug strategy gate**: 1 round of static analysis with no root cause → switch to instrument → reproduce → observe. No multi-session static exhaustion +- Full rules: `cat-cafe-skills/refs/shared-rules.md` §20 diff --git a/GEMINI.md b/GEMINI.md index 23ecc1cff9..972a34631a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,7 +1,8 @@ -# Clowder AI — Gemini Agent Guide +# Clowder AI — Agent Guide ## Identity -You are the Siamese cat (Gemini), the visual designer and creative thinker of this Clowder AI instance. +Your identity, role, and personality are provided by Cat Cafe at runtime. +This file contains shared governance rules that apply to all team members. ## Safety Rules (Iron Laws) 1. **Data Storage Sanctuary** — Never delete/flush persistent storage. @@ -9,13 +10,12 @@ You are the Siamese cat (Gemini), the visual designer and creative thinker of th 3. **Config Immutability** — Never modify runtime config files. 4. **Network Boundary** — Never access ports that don't belong to your service. -## Your Role -- Visual design and UX consultation -- Creative ideation and brainstorming -- Design system maintenance -- Breaking conventional thinking patterns - -## Important Constraints +## Gemini-Specific Constraints - Focus on design consultation, not code implementation - Always validate suggestions against the project's design system - Provide visual references when suggesting changes + +## Review Protocol +- Same individual cannot review their own code +- Cross-family review preferred +- Every finding must have a clear severity: P1 (blocking) / P2 (should fix) / P3 (nice to have) diff --git a/cat-cafe-skills/quality-gate/SKILL.md b/cat-cafe-skills/quality-gate/SKILL.md index 63d006e004..b6153c4522 100644 --- a/cat-cafe-skills/quality-gate/SKILL.md +++ b/cat-cafe-skills/quality-gate/SKILL.md @@ -61,6 +61,28 @@ Step 2: CREATE — 建检查清单 - 列出每一个 AC / 功能点 / 边界条件 - 列出 Discussion 里的 UX 描述和场景 +Step 2.4: PATCH COUNTER GATE(反复返工硬闸)🔴 + - **Step A — 列出候选 commits**: + ``` + UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo origin/main)" + BASE_REF="$(git merge-base "$UPSTREAM_REF" HEAD)" + git log --oneline "$BASE_REF..HEAD" -- + ``` + 自动取当前分支的 upstream tracking ref,无 tracking 时 fallback `origin/main`。develop_base 流程中会自动解析为 `origin/develop_base`。 + - **Step B — 人工分类**:逐条标记每个 commit 为以下之一: + - ✅ **同一 bug/AC 返工**:修上次没修好的同一个问题、用户报告同一问题后的重复修复、同一区域反复修补 + - ⬚ **正常迭代**(不计入):reviewer 正常 P1/P2 修复(`fix: address review P2-xxx`)、新发现的不同问题的修复 + - ⬚ **排除**(不计入):纯 lint/format/typo(`chore:` / `style:`)、rebase 冲突解决 + - 判断标准:问自己——"这个 fix 是在修一个**新发现的问题**,还是在修**上次没修好的同一个问题**?"后者才标 ✅ + - **Step C — 硬闸判定**:标 ✅ 的 ≥3 个 → **GATE FAIL** + - FAIL 时必须: + ① 停止继续修补 + ② 重读 spec 原文 + 铲屎官原始需求 + ③ 产出完整的真相源矩阵(格式同 writing-plans 的 Truth-Source Model Gate) + ④ 写清"为什么需要这么多 fix"的根因分析 + ⑤ 根因分析通过后才能继续 + - > **根因(2026-06-05 反思 + LL-020)**:F719 分支前 25 个提交大量 fix/refactor/test-fix,远超 LL-020 的"N > 3 换方向"告警线。补丁数量是方向信号——N > 3 不是"还需要更多补丁"的信号,而是"理解不完整,需要停下来重新建模"的信号。 + Step 2.5: CLOSE GATE MATRIX + FOLLOW-UP TAIL SCAN(F177 Phase A)🔴 - 检查 CloseGateReport 是否已生成(schema: `cat-cafe-skills/refs/close-gate.md`) - 每个 unmet AC 是否三选一处置(immediate / delete / cvo_signoff) diff --git a/cat-cafe-skills/receive-review/SKILL.md b/cat-cafe-skills/receive-review/SKILL.md index 93f8bedd1b..d6332fc09a 100644 --- a/cat-cafe-skills/receive-review/SKILL.md +++ b/cat-cafe-skills/receive-review/SKILL.md @@ -44,15 +44,31 @@ triggers: ## 核心知识 -### 两类反馈,处理方式不同 +### 三类反馈,处理方式不同 | 类型 | 特征 | 处理 | |------|------|------| | **代码级** | bug / edge case / 性能 / 命名 | Red→Green 修复流程 | +| **数据流级** | "真相源读错了" / "状态不一致" / "全局改了项目没级联" / "UI 和 API 对不上" | STOP → 重建真相源模型 → 再修(见下方) | | **愿景级** | "这不是operator要的" / "缺了多项目管理" / "UI 不可用" | STOP → 回读原始需求 → 升级operator | > **愿景级反馈不能用代码 patch 修补设计问题。** 先对照operator experience验证 reviewer 说得对吗;如确实偏离,升级operator确认偏差范围,再重新设计。 +### 数据流级反馈处理(反补锅匠门禁)🔴 + +当 reviewer 或用户指出的问题涉及**真相源/状态/级联/一致性**时,**禁止直接按点修**。必须: + +1. **STOP** — 不写一行代码 +2. **重建真相源模型** — 产出真相源矩阵(格式同 `writing-plans` 的 Truth-Source Model Gate),明确谁写谁读谁派生 +3. **定位根因** — 当前 bug 是哪个环节的读/写/级联断了?用矩阵定位,不用直觉猜 +4. **Blast radius 评估** — 修这个环节会影响哪些消费方?列出来,每个都需要验证 +5. **补 regression guard** — 对每个受影响的消费方,先写测试保护现有正确行为,再改代码 +6. **修复** — 在 regression guard 保护下修根因,不是修症状 + +**判别标准**:如果你的修复方案是"在前端读另一个字段 / 加一个 workaround / 在 UI 层补逻辑"——这大概率是在修症状。问自己:"后端/数据层的真相源对不对?"如果不对,先修数据层。 + +> **根因(2026-06-05 反思)**:布偶猫 F719 中 toggle 不更新时,先做了前端读 `mountedCount` 的 workaround 而不是修 API 层的 `cap.enabled` 写入。这是"收到反馈后仍按点修"的典型失败模式。数据流级问题的修法必须回到数据层,不能在展示层打补丁。 + ### 禁止的响应(表演性同意) ``` @@ -93,7 +109,7 @@ review 报告中必须包含 fallback 层数分析结果。 WHEN 收到 review 反馈: 1. READ — 完整读完,不要边读边反应。**R2+ 时额外动作**:回看上轮 finding 列表,标注每个 finding 的 failure-mode 类型,用于 AUDIT 步骤的同型判别 -2. CLASSIFY — 区分愿景级 vs 代码级;按 P1/P2/P3 分优先级 +2. CLASSIFY — 区分代码级 / 数据流级 / 愿景级(见上方三类反馈表);按 P1/P2/P3 分优先级。数据流级 = 涉及真相源/状态/级联/一致性 → 走数据流级处理流程 3. CLARIFY — 有不清晰的问题先全部问清,再动手 4. VERIFY — reviewer 说的问题真的存在吗?(见下方三道门) 5. AUDIT — failure-mode sweep(见下方 §16e 判别) diff --git a/cat-cafe-skills/refs/review-request-template.md b/cat-cafe-skills/refs/review-request-template.md index 44df643a79..9e914cfdbf 100644 --- a/cat-cafe-skills/refs/review-request-template.md +++ b/cat-cafe-skills/refs/review-request-template.md @@ -35,10 +35,22 @@ Why: {一句话} - 是否新建了并行 `Store` / `Queue` / `Router` / `Adapter` / `Dispatcher` / `Binding` - 若修改 `docs/architecture/ownership/cells/*.md`,是否确实改变了 owner / boundary / extension point / canonical anchor +## Invariant Matrix(涉及跨层状态同步/级联时必填) + +| 不变量 | 断言描述 | 验证方式 | +|--------|---------|---------| +| INV-1 | {描述} | {测试/手动验证} | + +## E2E User Path Evidence(涉及用户可感知功能时必填) + + +{端到端路径 + 命令/截图} + ## Open Questions ### 技术 OQ(给 reviewer) {需要 reviewer 特别关注的实现正确性/安全性/性能问题} +请 reviewer 逐条验证 Invariant Matrix 中的不变量是否被代码保持。 ### 价值 OQ(给 operator,如有) {需要 operator 判断的价值取舍——必须附 Decision Packet(格式见 `refs/decision-matrix.md`)} @@ -76,6 +88,8 @@ pnpm -r --if-present run build # 成功 - **附原始需求摘录(≤5 行)**,否则 reviewer 不审 - **附 Architecture Ownership 三字段**,否则 reviewer 不审 +- **涉及跨层状态同步/级联时附 Invariant Matrix**,reviewer 有权要求补上后再继续 +- **涉及用户可感知功能时附 E2E User Path Evidence**(引用 quality-gate Dogfood 输出) - 自检报告必须附上(从 quality-gate skill 输出) - 前端功能附截图证据 - 前端 review 需要起 dev 时,**必须附 review 沙盒 Path + Start Command + 实际端口** diff --git a/cat-cafe-skills/refs/shared-rules.md b/cat-cafe-skills/refs/shared-rules.md index 04ad7a349f..e8ae1d90e7 100644 --- a/cat-cafe-skills/refs/shared-rules.md +++ b/cat-cafe-skills/refs/shared-rules.md @@ -777,7 +777,46 @@ beforeEach(() => { **违反代价**:用真身份发测试 payload 到operator thread / 其他猫 thread,看起来像 spam / cron job / 幻觉。已发出去的消息**不可撤回**。 -## 20. 根目录卫生公约(F214 — 根目录运行时残留治理) +## 20. Agent 降级与成本纪律(2026-06-11 usage 分析教训) + +> 来源:usage 分析发现 93% 成本来自 subagent-heavy sessions。Agent/Workflow 派发是高杠杆操作——每次调用 ≈ 新开一个完整上下文窗口。 + +### 20a. Agent 降级反射(派发前必问) + +派 Agent / Explore / Workflow **前**,先问: + +1. **一个 grep/Read 能做吗?** → 能 → 直接 `grep -rn` / `Read` / `Bash`,不派 agent +2. **需要并行或隔离上下文吗?** → 不需要 → 直接做,不开 Workflow +3. **这个 subagent 需要 Opus 吗?** → 简单搜索/摘要/格式化 → 用 `model: 'haiku'` + +**典型降级场景**(直接用工具,不派 agent): +- 找函数定义 → `grep -rn "functionName" .` +- 找文件 → `find . -name "pattern"` +- 看某个符号的引用 → `grep -rn "symbolName" packages/` +- 读已知文件的某段 → `Read` tool + +**Explore agent 保留场景**(合理使用): +- 搜索目标未知,需要跨多目录多命名探索 +- `very thorough` 广度搜索,确实不知道在哪 + +### 20b. 调试策略切换门禁 + +调试(定位 bug / 理解行为)时: +- **静态分析 1 轮未定位** → 切换到"加诊断 → 复现 → 看数据"策略 +- **禁止**在同一方向穷举 2+ 轮 session 的静态分析(token 消耗巨大,收益递减) +- **观察优先于推理**:运行时日志 / 诊断 / 断点 > 反复读同一批文件推理 + +来源:消息消失 bug 调查——2 轮 session 穷举静态分析未定位,如果一开始加诊断 + 复现可能 30 分钟就定位。 + +### 20c. 长 session 成本意识 + +- `/loop` 和 `/schedule` 必须有明确退出条件,不做无限轮询 +- 调查类任务设心理 token budget——到预算 → 切换策略或求助,不在同方向死磕 +- Workflow 使用 `budget.remaining()` 做提前终止 + +--- + +## 21. 根目录卫生公约(F214 — 根目录运行时残留治理) > 来源:F214(2026-05-28)。ADR-010 / F023 管子目录代码 + docs 归档,不管根目录运行时残留——本节是 ADR-010 在根目录维度的补丁。 diff --git a/cat-cafe-skills/request-review/SKILL.md b/cat-cafe-skills/request-review/SKILL.md index 03c2e656b3..dfaf59e3d2 100644 --- a/cat-cafe-skills/request-review/SKILL.md +++ b/cat-cafe-skills/request-review/SKILL.md @@ -81,9 +81,13 @@ BEFORE 发 review 请求: 关键字段提醒: - **Original Requirements**: 必填,≤5 行operator experience + 来源文档路径,并明确请 reviewer 对照判断 - **Architecture Ownership**: 必填,列 `Architecture cell` / `Map delta` / `Why`,并请 reviewer 检查 diff 是否与 `Map delta` 一致 +- **Invariant Matrix**(涉及跨层状态同步/级联时必填;简单 CRUD 可写"不适用")🔴: 列出核心不变量 + 真相源读写关系(格式同 `writing-plans` 的 Truth-Source Model Gate)。reviewer 必须逐条验证 invariant 是否被代码保持。缺少 invariant matrix 时,reviewer 有权要求补上后再继续 review +- **E2E User Path Evidence**(涉及用户可感知功能时必填)🔴: 引用 quality-gate Step 4.5 Dogfood-Your-Slice 的输出即可(不需要重新跑)。reviewer 可据此判断 blast radius。若 quality-gate 中该项为"可豁免",此处同样豁免 - **Open Questions**: 分为两类——**技术 OQ**(给 reviewer 的,如实现正确性)和 **价值 OQ**(需要 operator 判断的,附 Decision Packet——格式见 `refs/decision-matrix.md`)。不混在一起 - **自检证据**: 附 quality-gate report 摘要 + 测试命令输出 + 根目录工件闸门输出 +> **根因(2026-06-05 反思)**:F719 review 中缅因猫发现的 P1 都指向同一类问题——global/project state 语义混淆、guard 读错真相源。如果 review 请求附了 invariant matrix,reviewer 能直接验证"代码是否保持了这些不变量",而不是在代码里逐行找状态不一致。E2E 用户路径证据则防止"测试通过但用户体验不通"的盲区。 + **F191 reviewer 视角**: - PR 是否新建了并行 `Store` / `Queue` / `Router` / `Adapter` / `Dispatcher` / `Binding`? - `Map delta: none` 是否与 diff 一致? diff --git a/cat-cafe-skills/writing-plans/SKILL.md b/cat-cafe-skills/writing-plans/SKILL.md index 3c1fa6b0f7..eb9f553aaf 100644 --- a/cat-cafe-skills/writing-plans/SKILL.md +++ b/cat-cafe-skills/writing-plans/SKILL.md @@ -25,6 +25,52 @@ triggers: **Save plans to:** `feature-specs/YYYY-MM-DD-.md` +## Truth-Source Model Gate(复杂需求强制)🔴 + +**写不出完整数据流 = 还没理解 = 不许拆步骤。** + +对于涉及**跨层状态同步或级联更新**的需求(如:全局配置→项目配置→文件系统→UI 多层联动),动手拆步骤前**必须**先产出以下三张表。简单的 CRUD 读写(API 读 DB 返回 JSON)不触发此门禁: + +### 表 1:真相源矩阵(谁写谁读谁派生) + +```markdown +| 数据 | 真相源(写) | 消费方(读) | 派生关系 | 级联规则 | +|------|-------------|-------------|---------|---------| +| 例:全局 skill 启禁用 | capabilities.json (全局) | 项目级 capabilities / UI | 项目继承全局 | 全局禁用 → 所有项目 unmount | +``` + +**每一行必须回答**:这个数据从哪来?谁负责写?谁读?读的人直接读真相源还是读派生?状态变化时谁级联通知谁? + +### 表 2:核心不变量(invariants) + +```markdown +- INV-1: 全局禁用的 skill,任何项目下都不可见且无 symlink +- INV-2: 项目配置的启禁用状态 = 实际 symlink 存在与否 +- INV-3: UI 展示的状态 = 后端 API 返回的状态(无前端 workaround) +``` + +**每条 invariant 必须可测试**——写不出对应的断言 = 不是 invariant,是愿望。 + +### 表 3:既有正确行为保护点 + +```markdown +| 现有功能 | 当前正确行为 | 保护方式 | +|---------|------------|---------| +| 项目选择器 | 能列出所有已知项目 | 现有测试 / 新增 regression guard | +``` + +**改代码前,先确认保护方式到位**(已有测试覆盖 or 需要先补测试)。没有保护 = 改了之后不知道有没有破坏。 + +### 何时豁免 + +- 单文件 ≤50 行改动、不涉及多数据源交互 → 可跳过 +- 纯 UI 样式调整、文档修改 → 可跳过 +- **涉及 config/state/filesystem 多层交互 → 不可跳过,写不出就停下来问清楚** + +> **根因(2026-06-05 反思)**:布偶猫 F719 Skill Lifecycle Management 反复返工的核心病因是"边做边理解"——抓关键词就动手,没花时间画完整数据流。铲屎官给了闭环方案但实现出来"长得像但数据流不通"。此门禁把"理解"从"实现"中分离:理解产物是三张表,不是代码。 + +--- + ## Straight-Line Check (A→B, No Detour) **Before splitting steps, do this first:** diff --git a/docs/public-lessons.md b/docs/public-lessons.md index 80e683ee7d..efaabe7f9d 100644 --- a/docs/public-lessons.md +++ b/docs/public-lessons.md @@ -90,7 +90,83 @@ created: 2026-02-26 --- -## 6) Maine Coon侧首批条目(AGENTS + Review + Skills) +## 6) 主题索引与高频核心 + +> ⭐ = 高频核心(日常协作反复踩的认知/流程护栏) + +**根因分析与方向纠偏** +- ⭐ LL-009: 关键前提不确定时,先提问再动作 +- LL-014: Bug 修复必须先写 Bug Report 再动手 +- LL-019: 过度修复反模式——根因修完后不要盲修触发器 +- ⭐ LL-020: 补丁数量是方向信号——N > 3 停下来复检方向 +- ⭐ LL-021: AI 倾向停在第一层"看起来合理"的答案,不主动追溯根因 +- ⭐ LL-058b: 热补丁反模式——已有治理机制不查就另起炉灶 + +**交付验证与证据纪律** +- ⭐ LL-006: 没有新鲜验证证据,不得宣称完成 +- ⭐ LL-029: 交付物验证不能只看 spec checkbox——必须核实 commit/PR +- ⭐ LL-031: Quality gate 逐字段对账 AC——文档承诺 ≠ 代码已兑现 +- ⭐ LL-032: 愿景守护不能只看代码和测试报告——必须真实启动 dev 跑一遍 +- ⭐ LL-041: 写完产物不主动打开 = 做了菜不端上桌 + +**Review 与协作纪律** +- LL-002: Review 问题必须先 Red 再 Green,禁止先改后补测 +- LL-003: Reviewer 必须有立场,Author 必须技术性 push back +- LL-004: P1/P2 当轮清零,P3 当场决断,不挂债务 +- LL-005: 修完 review 后必须回给 reviewer 二次确认再合 main +- LL-033: 云端 review 不能只看 review body state——必须检查 inline code comments + +**Worktree / Runtime / 数据安全** +- LL-008: Worktree 生命周期必须成套执行 +- LL-010: 删除文件必须用 trash,禁止 /bin/rm +- LL-011: Worktree 清理的正确顺序——先 push,再 cd 回主仓,最后 remove +- LL-012: 不要 --force 删有猫在工作的 worktree +- LL-015: Worktree 开发必须用独立 Redis 端口(6398) +- LL-045: Runtime worktree 反复被猫污染 +- ⭐ LL-049: `pnpm dev:direct` 无差别杀端口——review 踢翻 runtime +- LL-055: spawn 出的"长尾 child runtime"必须能脱离 parent 自动死亡 +- LL-056: stale browser profile 不是 orphan——cleanup 要按资源所有权分组 + +**知识工程与配置漂移** +- LL-001: 提炼教训前先做时效性验证 +- LL-007: 交接缺 Why 会让接手方无法判断 +- LL-025: 协作规则不能写死个体名,必须引用角色 +- LL-027: Feature spec 与代码实现的时间线漂移 +- LL-028: "最小实现"不等于"做个玩具再重写" +- LL-030: 共享脚本改默认值,同 commit 必须补显式环境值 +- LL-037: 共享记忆塑造视角——团队文化比模型参数更能影响判断趋同 +- LL-042: 配置真相源不加门禁就会漂移 +- LL-050: ADR 漂移 2 个月无人发现 +- LL-057: root prompt 重复可能是兼容副本,不是天然垃圾 + +**工具与基础设施细节** +- LL-013: Git commit 前必须检查暂存区 +- LL-016: ioredis keyPrefix 对 eval() 和 keys() 的行为不一致 +- LL-017: CAS 比较必须基于不可变快照 +- LL-018: Session 存储必须按 Thread 隔离 +- LL-022: 治理基线必须脚本化 +- LL-023: CLI JSON 格式陷阱与 jq 安全防护 +- LL-024: 状态字段多点写入会复发蜘蛛网 +- LL-026: 身份信息是硬约束常量 +- LL-034: Embedding 实现偷懒 +- LL-035: sync-to-opensource rsync --delete 打穿 runtime +- LL-036: full sync 长跑不能在半路报喜 +- LL-038: Promise timeout 不等于 Promise 取消 +- LL-039: gate 里推进 cursor 等于"还没干活就划卡" +- LL-040: AI 写文档日期不能凭内部时间感 +- LL-043: 删旧层前必须证明迁移已落成 +- LL-044: Chrome IME 回车误提交 +- LL-046: AOF/RDB 持久化脱节 +- LL-047: Socket.IO cors 不保护 WebSocket +- LL-048: 用户可感知状态禁止默认 TTL +- LL-051: 实验框架空转 +- LL-052: exec VAR=val cmd 不设置环境变量 +- LL-053: 无头 Codex CLI 长任务不能靠 shell 伪后台 +- LL-054: 猫的 callback env 泄漏到 unit test 子进程——用真身份发出 6 条 'hi' + +--- + +## 7) 条目 ### LL-002: Review 问题必须先 Red 再 Green,禁止先改后补测 - 状态:validated @@ -162,7 +238,7 @@ created: 2026-02-26 - 关联: - `cat-cafe-skills/merge-approval-gate/SKILL.md` - - `review-notes/README.md` + - *(internal reference removed)* ### LL-006: 没有新鲜验证证据,不得宣称完成 - 状态:validated @@ -198,7 +274,7 @@ created: 2026-02-26 - 关联: - `cat-cafe-skills/cross-cat-handoff/SKILL.md` - - `review-notes/README.md` + - *(internal reference removed)* ### LL-008: Worktree 生命周期必须成套执行(建-收敛-合入-清理) - 状态:validated @@ -238,10 +314,6 @@ created: 2026-02-26 - `cat-cafe-skills/systematic-debugging/SKILL.md` - `cat-cafe-skills/cat-cafe-receiving-review/SKILL.md` ---- - -## 7) Ragdoll侧首批条目(CLAUDE.md + Bug Report + Skills) - ### LL-010: 删除文件必须用 trash,禁止 /bin/rm - 状态:validated - 更新时间:2026-02-13 @@ -354,20 +426,6 @@ created: 2026-02-26 - 关联:CLAUDE.md §7 Redis 测试规则 | ADR-008 Lua 原子操作 -### LL-023: CLI JSON 格式陷阱与 `jq` 安全防护 -- 状态:draft -- 更新时间:2026-02-19 - -- 坑:在 CLI 中手动拼接带变量的 JSON 字符串(如 `curl` 调用 API)时,极易因双引号转义、多层嵌套或变量内容包含特殊字符而导致 JSON 格式损坏,甚至导致消息发送失败或变成“只有用户可见”的悄悄话。 -- 根因:手动拼接 JSON 违反了“数据与格式分离”原则,AI 对 Shell 转义规则(尤其是多层引号)的处理在复杂场景下不可靠。 -- 触发条件:通过 `curl` 调用含有环境变量(如 `$CAT_CAFE_INVOCATION_ID`)的 API,且消息内容包含引号、换行或表情符号时。 -- 修复:强制使用 `jq` 构造 JSON(例如:`jq -nc --arg c "$MSG" '{content: $c}'`),利用工具确保内容被自动转义。 -- 防护:更新所有 Agent 的提示词模板,将 `curl` 示例改为 `jq` 构造法;在 `GEMINI.md` 中增加醒目警告。 -- 来源锚点: - - `GEMINI.md` (2026-02-19 更新) - - 2026-02-19 Siamese(Gemini)“猫猫杀”游戏调试过程 -- 原理:结构化数据必须由结构化工具生成。在命令行环境中,`jq` 是保证数据序列化健壮性的事实标准。 - ### LL-017: CAS 比较必须基于不可变快照,不能用内存活引用 - 状态:validated - 更新时间:2026-02-13 @@ -463,12 +521,26 @@ created: 2026-02-26 - 防护:P0 验收前与后续回归中运行健康脚本;失败即阻断“可用”结论。 - 来源锚点: - `scripts/hindsight/p0-health-check.sh` - - `project-runbooks/hindsight-p0-health-check.md` + - *(internal reference removed)* - *(internal reference removed)* - 原理:治理有效性不是“策略存在”,而是“策略被持续验证”。没有自动化检查的治理,等同于没有治理。 - 关联:`docs/decisions/005-hindsight-integration-decisions.md` | `docs/ROADMAP.md` | Task 4 可观测检查 +### LL-023: CLI JSON 格式陷阱与 `jq` 安全防护 +- 状态:draft +- 更新时间:2026-02-19 + +- 坑:在 CLI 中手动拼接带变量的 JSON 字符串(如 `curl` 调用 API)时,极易因双引号转义、多层嵌套或变量内容包含特殊字符而导致 JSON 格式损坏,甚至导致消息发送失败或变成"只有用户可见"的悄悄话。 +- 根因:手动拼接 JSON 违反了"数据与格式分离"原则,AI 对 Shell 转义规则(尤其是多层引号)的处理在复杂场景下不可靠。 +- 触发条件:通过 `curl` 调用含有环境变量(如 `$CAT_CAFE_INVOCATION_ID`)的 API,且消息内容包含引号、换行或表情符号时。 +- 修复:强制使用 `jq` 构造 JSON(例如:`jq -nc --arg c "$MSG" '{content: $c}'`),利用工具确保内容被自动转义。 +- 防护:更新所有 Agent 的提示词模板,将 `curl` 示例改为 `jq` 构造法;在 `GEMINI.md` 中增加醒目警告。 +- 来源锚点: + - `GEMINI.md` (2026-02-19 更新) + - 2026-02-19 Siamese(Gemini)"猫猫杀"游戏调试过程 +- 原理:结构化数据必须由结构化工具生成。在命令行环境中,`jq` 是保证数据序列化健壮性的事实标准。 + ### LL-024: 状态字段多点写入会复发蜘蛛网 - 状态:validated - 更新时间:2026-02-27 @@ -1233,20 +1305,20 @@ created: 2026-02-26 - 状态:draft - 更新时间:2026-05-28 -- 坑:用户明确要“精美架构设计图 / 华为风 / 白底红黑 / 图片”时,Codex 第四五六次仍进入“先写 SVG 再转 PNG”的 coder 反射,产物方向错,且重复踩同一坑。 +- 坑:用户明确要”精美架构设计图 / 华为风 / 白底红黑 / 图片”时,Codex 第四五六次仍进入”先写 SVG 再转 PNG”的 coder 反射,产物方向错,且重复踩同一坑。 - 根因: - 1. 旧规则只是“默认建议”,没有进入执行前硬闸;一旦进入“文字可控、布局可控”的工程反射,imagegen 被错误降级成可选项。 - 2. 把“架构图需要精确”误判成“必须代码渲染”,但用户真正验收的是视觉完成度,而不是 SVG 源文件。 - 3. 已有猫档明确写了“Maine Coon原生图片生成强、禁止用 SVG 画”,但能力唤醒没有把这条转成 preflight。 -- 触发条件:复杂架构图、PPT 页面、企业信息图、华为风 / 红白黑风格、已有低保真蓝图但用户要求“精美图 / 终稿 / 图片”,且没有明确要求可编辑源文件。 -- 修复:已在 `cat-cafe-skills/image-generation/SKILL.md` 增加“Codex SVG 复发熔断闸”,匹配上述场景时禁止先写 SVG/HTML/Canvas,必须先原生 imagegen 整页直出。 + 1. 旧规则只是”默认建议”,没有进入执行前硬闸;一旦进入”文字可控、布局可控”的工程反射,imagegen 被错误降级成可选项。 + 2. 把”架构图需要精确”误判成”必须代码渲染”,但用户真正验收的是视觉完成度,而不是 SVG 源文件。 + 3. 已有猫档明确写了”Maine Coon原生图片生成强、禁止用 SVG 画”,但能力唤醒没有把这条转成 preflight。 +- 触发条件:复杂架构图、PPT 页面、企业信息图、华为风 / 红白黑风格、已有低保真蓝图但用户要求”精美图 / 终稿 / 图片”,且没有明确要求可编辑源文件。 +- 修复:已在 `cat-cafe-skills/image-generation/SKILL.md` 增加”Codex SVG 复发熔断闸”,匹配上述场景时禁止先写 SVG/HTML/Canvas,必须先原生 imagegen 整页直出。 - 防护: 1. image-generation skill 的 preflight:复杂架构/PPT/精美图 + 无可编辑要求 = imagegen-first。 2. SVG/HTML 降级必须写出 `SVG override reason`,且只能基于已失败的 imagegen 产物或用户显式可编辑要求。 3. “中文文字更可控 / 布局更可控 / 架构图需要精确 / 先 SVG 再转 PNG”都不是合格 override 理由。 - 来源锚点: - `cat-cafe-skills/image-generation/SKILL.md`(Codex SVG 复发熔断闸) - - `docs/team/cat-dossier.md#L122`(Maine Coon原生图片生成能力与“禁止用 SVG 画”事故记录) + - `docs/team/cat-dossier.md#L122`(Maine Coon原生图片生成能力与”禁止用 SVG 画”事故记录) - 2026-05-28 LLE 自进化平台三张图生成事故复盘 - 原理:**能力唤醒必须落到执行前硬闸。** “知道自己应该 imagegen”不等于会在任务压力下选择 imagegen;对复发型坏直觉,要把建议升级成 preflight + override reason。 @@ -1254,6 +1326,32 @@ created: 2026-02-26 --- +### LL-058b: 热补丁反模式——已有治理机制不查就另起炉灶 +- 状态:draft +- 更新时间:2026-04-30 + +- 坑:skill symlinks 指向旧版本,直接手动批量重链接 HOME 目录 + 改 `sync-skills.sh`,没有先分析已有的 worktree skill、`sync-skills.sh`、`check-skills-mount.sh`、`skill-sync.ts` 四层治理链条。同一天在 #598 verdict scope boundary 上也犯了类似错误——没分析 thread 根因、没和 reviewer 沟通就直接改了三个 skill 文件。 +- 根因: + 1. **对已有机制缺乏检索习惯**:问题出现后直接跳到”怎么修”,没有先问”已有什么机制在处理这个”。 + 2. **把”能跑”等同于”方案正确”**:手动 symlink 确实能让当前 session 读到新 skill,但绕过了 worktree 创建时的自动同步链条,制造了新的不一致。 + 3. **执行速度 > 方向正确**(LL-009 复发):铲屎官两次叫停,说明节奏判断失误不是个案。 +- 触发条件:发现”状态不对”时的冲动修复——尤其是 symlink/config/环境变量类问题,手动修一个很快,但会绕过治理链。 +- 修复:回退 `sync-skills.sh` 热补丁(`97ef3c2d`),改为先做根因分析 → 盘点已有机制 → 和 codex 对齐方案 → 开 worktree 走正常合入流程。 +- 防护: + 1. 发现问题后第一步:`grep -r` / `git log` 搜已有机制(脚本、治理服务、skill 步骤) + 2. 盘点完才提方案,方案必须说明”在已有机制的哪一层修” + 3. 改动走 worktree → review → merge-gate,不直接在 develop_base 上热补丁 +- 来源锚点: + - `scripts/sync-skills.sh` | `scripts/check-skills-mount.sh` | `cat-cafe-skills/worktree/SKILL.md` | `packages/api/src/config/governance/skill-sync.ts` + - commit:`482b6f27`(sync-skills.sh 热补丁)→ commit:`97ef3c2d`(revert) + - commit:`82972f45` / `453a4b54`(#598 verdict scope boundary 规则修正) + - thread:`thread_moicgl47en8m98do`(铲屎官两次叫停的原始对话) +- 原理:**治理机制存在的意义是把”正确做法”编码成自动化。绕过它 = 把一次性修复变成永久的手动负担,还会让治理机制逐渐失效(因为大家习惯绕过)。** + +- 关联:LL-009 | LL-020 | `cat-cafe-skills/refs/shared-rules.md` §P3 + +--- + ## 8) 维护约定 - 本文件是入口,不替代 ADR/bug-report 原文。 diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts index ec09295e5d..865874543c 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts @@ -52,8 +52,16 @@ export function createA2aGeneratorAdapter(): VerdictGenerator { copyFileSync(liveRefs.refs.snapshotPath, isoSnapPath); copyFileSync(liveRefs.refs.attributionPath, isoAttrPath); - // Load domain entry from registry inside the isolated worktree's harness root. - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: load the domain entry from the LIVE registry, NOT the + // isolated worktree's. The isolated worktree is built from the publish base + // branch (origin/main), which in a fork lags the runtime: the live registry + // can carry schema fields (e.g. required `sourceRefsKind`) that the base + // branch hasn't yet. Re-parsing the stale base registry here threw + // `generator_failed: sourceRefsKind Required` and blocked ALL eval:a2a + // publishes. The registry is the runtime contract — the handler already + // validated against the live root, and this adapter already resolves its + // evidence YAMLs from the live root — so read the domain from there too. + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts index afa9e5361f..44205ffe33 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts @@ -56,7 +56,10 @@ export function createCapabilityWakeupGeneratorAdapter(provider: CapabilityWakeu ); } - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts index ba2974b55a..cc15d3c687 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts @@ -43,7 +43,10 @@ export function createFrictionGeneratorAdapter(provider: FrictionMetricsProvider const rollupInput = await provider.resolve(selector); - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts index b6531e688c..c2726931da 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts @@ -28,9 +28,68 @@ export interface GitWorktreePublisherDeps { repoRoot: string; } +/** + * Parse `owner/repo` out of a git remote URL. + * + * 砚砚 2026-06-17 P1: the publisher pushes the verdict branch to `origin`, but + * every `gh` call previously relied on cwd auto-detection. In a fork checkout + * that has BOTH `origin` (the fork, e.g. mindfn/clowder-ai) AND `upstream` + * (e.g. zts212653/clowder-ai), `gh` resolves the base repo to the UPSTREAM + * parent — so `gh pr create --head ` looks for the branch in the wrong + * repo and fails with "Head sha can't be blank / Head ref must be a branch". + * Worse, the failure-cleanup `gh pr list` probe would query upstream, see no PR + * for the pushed branch, decide `safeToDelete`, and delete the origin branch a + * live PR depends on. + * + * Deriving owner/repo from `origin` and passing `--repo ` to every + * `gh` invocation makes the target explicit. In a single-remote upstream + * checkout this is a no-op (origin IS the repo); in a fork it fixes resolution. + * + * Handles the URL forms `git`/`gh` actually emit: + * - scp-like SSH: git@github.com:owner/repo(.git) + * - ssh://: ssh://git@github.com/owner/repo(.git) + * - https://: https://github.com/owner/repo(.git) + * - https w/ cred: https://user@github.com/owner/repo(.git) + */ +export function parseOwnerRepoFromGitRemoteUrl(remoteUrl: string): string { + const url = remoteUrl.trim(); + if (!url) throw new Error('cannot derive owner/repo: empty git remote url'); + + let path: string; + const scpMatch = url.match(/^[^/]+@[^/:]+:(.+)$/); + if (scpMatch && !url.includes('://')) { + // scp-like SSH: git@host:owner/repo + path = scpMatch[1] ?? ''; + } else { + // url form (ssh:// or https://) — strip scheme + host, keep the path + const afterScheme = url.replace(/^[a-z]+:\/\//i, ''); + const firstSlash = afterScheme.indexOf('/'); + path = firstSlash === -1 ? '' : afterScheme.slice(firstSlash + 1); + } + + const ownerRepo = path.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, ''); + const segments = ownerRepo.split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error(`cannot derive owner/repo from git remote url: ${remoteUrl}`); + } + // owner/repo are the LAST two segments (defensive against nested self-hosted paths). + return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; +} + export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitPublisher { return { async publishOnIsolatedWorktree(opts: PublishOnIsolatedWorktreeOpts) { + // 砚砚 2026-06-17 P1: derive the explicit gh target from `origin` (where we + // push the branch) FIRST — before any side effect — so the failure-cleanup + // `gh pr list` probe in `finally` targets the right repo. Doing this before + // mkdtempSync means a missing/invalid `origin` throws with NO temp dir + // created (砚砚 P3: the try/finally cleanup hasn't been entered yet, so an + // origin-lookup failure can't leak a temp worktree dir). + const originUrlResult = await exec('git', ['-C', deps.repoRoot, 'remote', 'get-url', 'origin'], { + timeout: 10_000, + }); + const originRepo = parseOwnerRepoFromGitRemoteUrl(originUrlResult.stdout.trim()); + // Use mkdtemp to get a guaranteed-unique path; suffix with PID for debuggability const worktreePath = mkdtempSync(`${tmpdir()}/cat-cafe-publish-verdict-${process.pid}-`); @@ -95,9 +154,10 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const commitSha = shaResult.stdout.trim(); // 7. Open auto-PR via gh. - // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax (fails with - // 'expected the "[HOST/]OWNER/REPO" format'). Rely on cwd inside the - // worktree — gh auto-detects owner/repo from the git remote. + // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax. 砚砚 2026-06-17 P1: + // cwd auto-detection picks the UPSTREAM parent in a fork checkout (origin + // + upstream remotes), so we pass the explicit `--repo ` + // derived above. This matches where `git push -u origin` put the branch. // // PR-3 (砚砚 R2): pass each label via separate `--label` flag (gh CLI accepts // repeated --label X; not comma-separated). `computePublishPolicy` decides @@ -120,7 +180,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP }; for (const label of labels ?? []) { const meta = standardLabelMeta[label]; - const args = ['label', 'create', label, '--force']; + const args = ['label', 'create', label, '--repo', originRepo, '--force']; if (meta) { args.push('--color', meta.color, '--description', meta.description); } @@ -138,6 +198,8 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP [ 'pr', 'create', + '--repo', + originRepo, '--base', 'main', '--head', @@ -168,6 +230,8 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP 'pr', 'close', prUrl, + '--repo', + originRepo, '--delete-branch', '--comment', 'Closing stale auto-verdict PR because post-publish writeback failed.', @@ -225,7 +289,20 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP try { const probe = await exec( 'gh', - ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], + [ + 'pr', + 'list', + '--repo', + originRepo, + '--head', + opts.branchName, + '--state', + 'open', + '--json', + 'state', + '--limit', + '1', + ], { cwd: deps.repoRoot, timeout: 30_000 }, ); const parsed = JSON.parse(probe.stdout) as Array<{ state?: string }>; diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts index 96f91c8f29..0e5380cf77 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts @@ -50,7 +50,10 @@ export function createMemoryGeneratorAdapter(provider: MemoryMetricsProvider): V const { recallMetrics, libraryHealth } = await provider.resolve(selector); - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts index eac0721bf0..8a59eb2460 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts @@ -17,7 +17,10 @@ export function createTaskOutcomeGeneratorAdapter(): VerdictGenerator { ); } - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 3a78cd685c..cf54986148 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -1789,6 +1789,30 @@ export const messagesRoutes: FastifyPluginAsync = async ( const hasMore = allVisible.length > limit || !storeExhausted; const page = allVisible.length > limit ? allVisible.slice(allVisible.length - limit) : allVisible; + // [DIAG:msg-disappear] Log codex messages for disappearance bug investigation + { + const codexMsgs = page.filter((m) => m.catId === 'codex'); + if (codexMsgs.length > 0 || resolvedThreadId.includes('mq5zrg04n1votqb9')) { + const hasDraftStore = Boolean(opts.draftStore); + const drafts = hasDraftStore ? await opts.draftStore!.getByThread(userId, resolvedThreadId) : []; + request.log.info( + { + diag: 'msg-disappear', + threadId: resolvedThreadId, + totalFetched: allVisible.length, + pageSize: page.length, + hasMore, + codexCount: codexMsgs.length, + codexIds: codexMsgs.map((m) => m.id), + draftCount: drafts.length, + draftCats: drafts.map((d) => d.catId), + allCatIds: [...new Set(page.map((m) => m.catId).filter(Boolean))], + }, + '[DIAG:msg-disappear] /api/messages response', + ); + } + } + // Map chat messages (union type allows summary items to be pushed later) type TimelineItem = { id: string; diff --git a/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js b/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js index cf9460574c..83b9b354e6 100644 --- a/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js +++ b/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js @@ -202,7 +202,10 @@ describe('createCapabilityWakeupGeneratorAdapter', () => { it('happy path: passes packet+trials+domain to generator and returns artifact paths', async () => { const repoRoot = mkdtempSync(join(tmpdir(), 'cw-adapter-happy-repo-')); const harnessFeedbackRoot = join(repoRoot, 'docs', 'harness-feedback'); - seedDomainRegistry(harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: registry is read from the LIVE root now. Bundle/raw inputs + // still write under harnessFeedbackRoot (the isolated worktree target). + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + seedDomainRegistry(liveRoot); let resolveCalledWith = null; let resolveScope = null; const provider = { @@ -225,7 +228,7 @@ describe('createCapabilityWakeupGeneratorAdapter', () => { const result = await adapter(packet, selector, { harnessFeedbackRoot, - liveHarnessFeedbackRoot: '/tmp/live-unused-for-cw', + liveHarnessFeedbackRoot: liveRoot, ownerUserId: 'default-user', }); diff --git a/packages/api/test/harness-eval/friction-generator-adapter.test.js b/packages/api/test/harness-eval/friction-generator-adapter.test.js index 80c1df05c7..cb0bbcd37c 100644 --- a/packages/api/test/harness-eval/friction-generator-adapter.test.js +++ b/packages/api/test/harness-eval/friction-generator-adapter.test.js @@ -165,8 +165,11 @@ describe('createFrictionGeneratorAdapter', () => { it('happy path: passes selector to provider unchanged and returns bundle-only artifact paths', async () => { const repoRoot = mkdtempSync(join(tmpdir(), 'friction-adapter-happy-repo-')); - const harnessFeedbackRoot = join(repoRoot, 'docs', 'harness-feedback'); - seedFrictionDomain(harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: the domain registry is read from the LIVE root now (runtime + // contract), not the isolated bundle-write root. Seed it accordingly. + const isolatedRoot = join(repoRoot, 'iso', 'docs', 'harness-feedback'); + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + seedFrictionDomain(liveRoot); let resolveCalledWith = null; const provider = { @@ -179,8 +182,8 @@ describe('createFrictionGeneratorAdapter', () => { const packet = buildSubmittedPacket(); const result = await adapter(packet, SELECTOR, { - harnessFeedbackRoot, - liveHarnessFeedbackRoot: '/tmp/live-unused-for-friction', + harnessFeedbackRoot: isolatedRoot, + liveHarnessFeedbackRoot: liveRoot, }); assert.deepEqual(resolveCalledWith, SELECTOR, 'adapter passes selector to provider unchanged'); @@ -196,4 +199,61 @@ describe('createFrictionGeneratorAdapter', () => { const snapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); assert.equal(snapshot.featureId, 'F245'); }); + + // 砚砚 2026-06-24 P1 regression: the generator must read the domain registry from + // the LIVE root, NOT the isolated publish-base worktree. In a fork the isolated + // worktree is built from origin/main, which lags the runtime and can carry a + // STALE registry (e.g. missing the now-required `sourceRefsKind`). Re-parsing + // that stale registry threw `generator_failed: sourceRefsKind Required` and + // blocked ALL eval publishes. This pins that the live registry is the source. + it('reads registry from LIVE root, immune to a stale base-branch registry (sourceRefsKind missing)', async () => { + const repoRoot = mkdtempSync(join(tmpdir(), 'friction-adapter-stale-base-')); + const isolatedRoot = join(repoRoot, 'iso', 'docs', 'harness-feedback'); + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + + // Isolated (origin/main base) carries a STALE eval-friction.yaml WITHOUT + // sourceRefsKind — exactly the artifact that threw on parse pre-fix. + const isoDomainsDir = join(isolatedRoot, 'eval-domains'); + mkdirSync(isoDomainsDir, { recursive: true }); + writeFileSync( + join(isoDomainsDir, 'eval-friction.yaml'), + `domainId: eval:friction +displayName: Friction Signal Eval +systemThreadId: thread_eval_friction +evalCat: + catId: gpt52 + handle: '@gpt52' + model: gpt-5.4 +frequency: weekly +sourceAdapter: f245-friction-rollup +threadPolicy: + role: working-home + stateSot: registry + allowedContent: + - longitudinal-analysis +legacyScheduledTaskIds: [] +handoffTargetResolver: + featureId: F245 + ownerCatId: opus-47 + threadLookup: feature-thread +sla: + acknowledgeHours: 48 + reevalWithinHours: 168 +fixtures: [] +enabled: true +`, + ); + // Live (runtime) carries the current registry WITH sourceRefsKind. + seedFrictionDomain(liveRoot); + + const provider = { resolve: async () => buildRollupInput({ clusters: 1 }) }; + const adapter = createFrictionGeneratorAdapter(provider); + + // Must NOT throw sourceRefsKind / unknown_domain — succeeds via the live registry. + const result = await adapter(buildSubmittedPacket(), SELECTOR, { + harnessFeedbackRoot: isolatedRoot, + liveHarnessFeedbackRoot: liveRoot, + }); + assert.match(result.bundleDir, /bundles\/vhp-friction-adapter-test$/); + }); }); diff --git a/packages/api/test/harness-eval/git-worktree-publisher.test.js b/packages/api/test/harness-eval/git-worktree-publisher.test.js index 1a595d54ec..cbbf66289b 100644 --- a/packages/api/test/harness-eval/git-worktree-publisher.test.js +++ b/packages/api/test/harness-eval/git-worktree-publisher.test.js @@ -38,6 +38,41 @@ afterEach(() => { syncBuiltinESMExports(); }); +describe('parseOwnerRepoFromGitRemoteUrl', () => { + it('parses every git/gh remote URL form to owner/repo (砚砚 2026-06-17 P1)', async () => { + const { parseOwnerRepoFromGitRemoteUrl } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js' + ); + const cases = [ + // scp-like SSH (what `git remote get-url origin` returns for github SSH) + ['git@github.com:mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + ['git@github.com:mindfn/clowder-ai', 'mindfn/clowder-ai'], + // ssh:// form + ['ssh://git@github.com/zts212653/clowder-ai.git', 'zts212653/clowder-ai'], + // https forms (with and without .git, with and without creds) + ['https://github.com/mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + ['https://github.com/mindfn/clowder-ai', 'mindfn/clowder-ai'], + ['https://x-access-token:ghp_abc@github.com/mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + // trailing slash tolerance + ['https://github.com/mindfn/clowder-ai/', 'mindfn/clowder-ai'], + // whitespace (stdout trim defense-in-depth) + [' git@github.com:mindfn/clowder-ai.git\n', 'mindfn/clowder-ai'], + ]; + for (const [input, expected] of cases) { + assert.equal(parseOwnerRepoFromGitRemoteUrl(input), expected, `failed for input: ${JSON.stringify(input)}`); + } + }); + + it('throws on URLs that have no owner/repo (defensive)', async () => { + const { parseOwnerRepoFromGitRemoteUrl } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js' + ); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl(''), /empty git remote url/); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl('git@github.com:justrepo'), /cannot derive owner\/repo/); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl('https://github.com/onlyowner'), /cannot derive owner\/repo/); + }); +}); + describe('createGitWorktreePublisher', () => { it('cleans up a partially-created local branch when worktree add fails before stage', async (t) => { const { repoRoot, remoteRoot } = createRepoWithOrigin(); diff --git a/packages/mcp-server/src/tools/callback-outbox.ts b/packages/mcp-server/src/tools/callback-outbox.ts index a0b79a1173..e37748a387 100644 --- a/packages/mcp-server/src/tools/callback-outbox.ts +++ b/packages/mcp-server/src/tools/callback-outbox.ts @@ -38,24 +38,24 @@ function parseIntEnv(raw: string | undefined): number | null { } function isOutboxEnabled(): boolean { - const raw = (process.env['CAT_CAFE_CALLBACK_OUTBOX_ENABLED'] ?? 'true').toLowerCase(); + const raw = (process.env.CAT_CAFE_CALLBACK_OUTBOX_ENABLED ?? 'true').toLowerCase(); return raw !== '0' && raw !== 'false' && raw !== 'off'; } function getOutboxDir(): string { - const fromEnv = process.env['CAT_CAFE_CALLBACK_OUTBOX_DIR']; + const fromEnv = process.env.CAT_CAFE_CALLBACK_OUTBOX_DIR; if (fromEnv && fromEnv.trim().length > 0) return fromEnv; return join(homedir(), '.cat-cafe', 'callback-outbox'); } function getOutboxMaxFlushBatch(): number { - const parsed = parseIntEnv(process.env['CAT_CAFE_CALLBACK_OUTBOX_MAX_FLUSH_BATCH']); + const parsed = parseIntEnv(process.env.CAT_CAFE_CALLBACK_OUTBOX_MAX_FLUSH_BATCH); if (parsed === null || parsed < 0) return DEFAULT_OUTBOX_MAX_FLUSH_BATCH; return parsed; } function getOutboxMaxAttempts(): number { - const parsed = parseIntEnv(process.env['CAT_CAFE_CALLBACK_OUTBOX_MAX_ATTEMPTS']); + const parsed = parseIntEnv(process.env.CAT_CAFE_CALLBACK_OUTBOX_MAX_ATTEMPTS); if (parsed === null || parsed < 0) return DEFAULT_OUTBOX_MAX_ATTEMPTS; return parsed; } @@ -176,6 +176,11 @@ export async function sendCallbackRequest( const enableOutbox = options?.enableOutbox === true && isOutboxEnabled(); if (enableOutbox) await flushOutbox(); + // 砚砚 2026-06-17 P1: allow per-call retry-delays + timeout override. Publish- + // verdict passes `[]` (single attempt, no retry) because the route is long + + // side-effectful: auto-retry fires overlapping server-side publishes that race + // on the same branch name. Idempotency guards (verdict_already_exists) are the + // safety net for "did it publish", not client retries. const retryDelaysMs = options?.retryDelaysMs ?? getRetryDelaysMs(); const payload = JSON.stringify(request.body); const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs, request.headers, { diff --git a/packages/mcp-server/src/tools/callback-retry.ts b/packages/mcp-server/src/tools/callback-retry.ts index 613ee7c84d..b96eb181d3 100644 --- a/packages/mcp-server/src/tools/callback-retry.ts +++ b/packages/mcp-server/src/tools/callback-retry.ts @@ -25,7 +25,7 @@ export interface PostJsonRetryOptions { } export function getRetryDelaysMs(): number[] { - const raw = process.env['CAT_CAFE_CALLBACK_RETRY_DELAYS_MS']; + const raw = process.env.CAT_CAFE_CALLBACK_RETRY_DELAYS_MS; if (!raw) return DEFAULT_RETRY_DELAYS_MS; const parsed = raw .split(',') @@ -35,7 +35,7 @@ export function getRetryDelaysMs(): number[] { } export function getFetchTimeoutMs(): number { - const raw = process.env['CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS']; + const raw = process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS; if (!raw) return DEFAULT_FETCH_TIMEOUT_MS; const parsed = Number.parseInt(raw.trim(), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FETCH_TIMEOUT_MS; diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index 781802f731..1c1549cf1d 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -179,8 +179,12 @@ export async function callbackPost( enableOutbox?: boolean; agentKeyCatId?: string; forceAgentKey?: boolean; - retryDelaysMs?: number[]; + // 砚砚 2026-06-17 P1: per-call overrides for long, side-effectful routes + // (cat_cafe_publish_verdict). fetchTimeoutMs widens the per-attempt abort + // bound; retryDelaysMs=[] disables auto-retry so the route is not POSTed + // concurrently (overlapping publishes race on the same branch). fetchTimeoutMs?: number; + retryDelaysMs?: number[]; }, ): Promise { const config = getCallbackConfig({ @@ -198,8 +202,8 @@ export async function callbackPost( }, { enableOutbox: options?.enableOutbox === true, - retryDelaysMs: options?.retryDelaysMs, - fetchTimeoutMs: options?.fetchTimeoutMs, + ...(options?.fetchTimeoutMs !== undefined ? { fetchTimeoutMs: options.fetchTimeoutMs } : {}), + ...(options?.retryDelaysMs !== undefined ? { retryDelaysMs: options.retryDelaysMs } : {}), }, ); if (result.ok) return successResult(JSON.stringify(result.data)); diff --git a/packages/mcp-server/src/tools/publish-verdict-tool.ts b/packages/mcp-server/src/tools/publish-verdict-tool.ts index 7356f04beb..bbd4b94ba1 100644 --- a/packages/mcp-server/src/tools/publish-verdict-tool.ts +++ b/packages/mcp-server/src/tools/publish-verdict-tool.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { callbackPost } from './callback-tools.js'; import type { ToolResult } from './file-tools.js'; -const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 120_000; +const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 180_000; /** * F192 Phase H AC-H4: cat_cafe_publish_verdict MCP tool. @@ -326,6 +326,13 @@ type PublishVerdictToolInput = { agentKeyCatId?: string | undefined; }; +// 砚砚 2026-06-17 P1: publish-verdict runs a synchronous git worktree + push + +// `gh pr create` on the server (~17s typical; the publisher's own step timeouts +// sum much higher). The default 10s-per-attempt + [1s,2s,4s] retry callback +// policy aborts before the route returns AND fires 4 overlapping server-side +// publishes that race on the same `verdict/auto//` branch. We give +// this one call a long single attempt with NO retry; idempotency guards on the +// server (verdict_already_exists / branch-exists) are the real safety net. export async function handlePublishVerdict(input: PublishVerdictToolInput): Promise { return callbackPost( `/api/eval-domains/${encodeURIComponent(input.domainId)}/publish-verdict`, diff --git a/packages/mcp-server/test/callback-retry.test.js b/packages/mcp-server/test/callback-retry.test.js index 0d570d5aee..0e4034167b 100644 --- a/packages/mcp-server/test/callback-retry.test.js +++ b/packages/mcp-server/test/callback-retry.test.js @@ -87,3 +87,81 @@ describe('postJsonWithRetry — fetch timeout (hung socket)', () => { assert.equal(result.ok, true, 'a fast response must not be aborted by the timeout'); }); }); + +// 砚砚 2026-06-17 P1: per-call timeout + no-retry override for long, side-effectful +// routes (cat_cafe_publish_verdict). Without these, the publish route (~17s) is +// aborted at 10s AND retried 3× → 4 overlapping server-side publishes racing on +// the same branch. +describe('postJsonWithRetry — per-call override (publish-verdict)', () => { + let originalEnv; + let originalFetch; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + globalThis.fetch = originalFetch; + }); + + test('retryDelaysMs=[] makes exactly ONE attempt on a 5xx (no overlapping retries)', async () => { + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let attemptCount = 0; + globalThis.fetch = async () => { + attemptCount += 1; + return { ok: false, status: 503, text: async () => 'busy', json: async () => ({}) }; + }; + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', []); + assert.equal(result.ok, false, '503 still surfaces as failure'); + assert.equal(attemptCount, 1, 'retryDelaysMs=[] must NOT retry — exactly one POST to a non-idempotent route'); + }); + + test('fetchTimeoutMs override widens the per-attempt bound beyond the env default', async () => { + // Global default forced tiny; the override must take precedence so a route + // that legitimately takes longer than the default is not falsely aborted. + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let observedTimeoutMs = null; + globalThis.fetch = (_url, opts) => + new Promise((resolve, reject) => { + // Capture the signal so we can prove the override (not the 20ms env) is in force. + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason)); + // Resolve at 80ms — would be aborted under the 20ms env default, but the + // 5000ms override keeps it alive. + setTimeout(() => { + observedTimeoutMs = 5000; + resolve({ ok: true, json: async () => ({ status: 'ok' }) }); + }, 80); + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', [], undefined, { + fetchTimeoutMs: 5000, + }); + assert.equal(result.ok, true, 'override (5000ms) must keep the 80ms response alive despite 20ms env default'); + assert.equal(observedTimeoutMs, 5000, 'response should have resolved under the override window'); + }); + + test('without override, the env default still aborts a slow fetch (override is opt-in)', async () => { + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + globalThis.fetch = (_url, opts) => + new Promise((_resolve, reject) => { + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason ?? new Error('aborted'))); + // never resolves on its own — only the 20ms timeout can settle it + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/x', '{}', []); + assert.equal(result.ok, false, 'no override → env default (20ms) still bounds the attempt'); + }); +}); diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index bb576e31bf..06e1094d37 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -4770,6 +4770,26 @@ export function useAgentMessages() { currentMessages: storeSnapshot.messages, }); if (result.recoveryAction === 'none') { + // [DIAG:msg-disappear] Trace done handler replaceMessages + const codexBefore = storeSnapshot.messages.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; + const codexAfter = result.nextMessages.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; + if (codexBefore !== codexAfter || msg.catId === 'codex') { + console.warn('[DIAG:msg-disappear] done handler replaceMessages', { + doneCatId: msg.catId, + isFinal: msg.isFinal, + invocationId: msg.invocationId, + codexBefore, + codexAfter, + totalBefore: storeSnapshot.messages.length, + totalAfter: result.nextMessages.length, + recoveryAction: result.recoveryAction, + violationCount: result.violations.length, + }); + } storeSnapshot.replaceMessages(result.nextMessages, storeSnapshot.hasMore); } if (result.violations.length > 0) { diff --git a/packages/web/src/hooks/useChatHistory.ts b/packages/web/src/hooks/useChatHistory.ts index 7ac1e8ba68..33d5c8c1d0 100644 --- a/packages/web/src/hooks/useChatHistory.ts +++ b/packages/web/src/hooks/useChatHistory.ts @@ -1002,6 +1002,42 @@ export function useChatHistory(threadId: string) { // (applyBubbleEventWithRecovery)。raw records 进 store 前 collapse // 到 1 bubble per (catId, invocationId),确保 hydrate ≡ live。 const projectedMerged = projectCanonicalBubbles({ records: mergedMsgs }).messages; + // [DIAG:msg-disappear] Trace codex message through hydration pipeline + if ( + fetchForThread.includes('mq5zrg04n1votqb9') || + mergedMsgs.some((m: { catId?: string | null }) => m.catId === 'codex') + ) { + const codexInHistory = historyMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexInMerged = mergedMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexInProjected = projectedMerged.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; + const codexIds = { + history: historyMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + merged: mergedMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + projected: projectedMerged + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + }; + console.warn('[DIAG:msg-disappear] replace hydration pipeline', { + threadId: fetchForThread, + historyCount: historyMsgs.length, + currentCount: currentState.messages.length, + mergedCount: mergedMsgs.length, + projectedCount: projectedMerged.length, + codexInHistory, + codexInMerged, + codexInProjected, + codexIds, + draftIds: historyMsgs + .filter((m: { id: string }) => m.id.startsWith('draft-')) + .map((m: { id: string }) => m.id), + }); + } hydrateThread(fetchForThread, projectedMerged, data.hasMore ?? false); restoreActiveFromDrafts(fetchForThread, data.messages ?? []); return true; diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 0074d336f0..0eba24c58d 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -1711,6 +1711,24 @@ export const useChatStore = create((set, get) => ({ // throw on bypass-of-reducer mutations. No-op when strict is off — keeps // production hot path free of the O(n) scan. forwardStoreInvariantViolationsStrict(msgs, get().currentThreadId); + // [DIAG:msg-disappear] Trace codex messages through replaceMessages + { + const prevMsgs = get().messages; + const codexBefore = prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexAfter = msgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + if (codexBefore > 0 && codexAfter === 0) { + console.warn('[DIAG:msg-disappear] replaceMessages DROPPED all codex messages!', { + codexBefore, + codexAfter, + totalBefore: prevMsgs.length, + totalAfter: msgs.length, + codexIdsBefore: prevMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + }); + console.trace('[DIAG:msg-disappear] replaceMessages caller stack'); + } + } set((state) => { revokeRemovedBlobUrls(state.messages, msgs); return { messages: msgs, hasMore }; @@ -1757,6 +1775,25 @@ export const useChatStore = create((set, get) => ({ // F183 Phase E AC-E2 (砚砚 R2 P1 fix): same strict-gate as the other // caller-driven writers. Runs only when strict mode is on. forwardStoreInvariantViolationsStrict(msgs, threadId); + // [DIAG:msg-disappear] Trace codex messages through hydrateThread + { + const prevMsgs = get().messages; + const codexBefore = prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexAfter = msgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + if (codexBefore > 0 && codexAfter === 0) { + console.warn('[DIAG:msg-disappear] hydrateThread DROPPED all codex messages!', { + threadId, + codexBefore, + codexAfter, + totalBefore: prevMsgs.length, + totalAfter: msgs.length, + codexIdsBefore: prevMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + }); + console.trace('[DIAG:msg-disappear] hydrateThread caller stack'); + } + } set((state) => { if (threadId === state.currentThreadId) { revokeRemovedBlobUrls(state.messages, msgs);