diff --git a/.cat-cafe/governance-registry.json b/.cat-cafe/governance-registry.json new file mode 100644 index 0000000000..e535e05f56 --- /dev/null +++ b/.cat-cafe/governance-registry.json @@ -0,0 +1,305 @@ +{ + "entries": [ + { + "packVersion": "1.4.1", + "checksum": "aa9b05eecf64", + "syncedAt": 1784099930802, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/quant-strategy" + }, + { + "packVersion": "1.3.0", + "checksum": "73e4bf742d44", + "syncedAt": 1780662054705, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/backtest/ndx100etf-a-strategy-clowder" + }, + { + "packVersion": "1.3.0", + "checksum": "73e4bf742d44", + "syncedAt": 1779867929947, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/Quant-Run" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097077032, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-e53a8c21-ec7a-4bcb-bbea-a3fe2ba7cb37/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097077402, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-96de4946-448a-4a04-9add-4195f57e92c2/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097108345, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-36ed4d21-f024-425f-ad80-2b7c07ff7d86" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097108686, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-623a1a0e-41fd-4671-ba31-efe81fbe15f8" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097198670, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-49491347-11bf-4864-87fd-f358fbfa54ba/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097198917, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-a76b90a9-15c8-4360-97b8-92dd6115ccd4/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097234413, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-45362fee-1b19-486d-a06a-f072360048cd" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097234762, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-de89b95b-6828-4631-abef-cb66ddd277c6" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097345970, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-15e4dee1-18f9-4163-a47f-8e85ce707b8f/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097346337, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-d501cf0a-ba8c-47e7-b03c-2e3e90325912/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097382060, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-935cbe4b-2812-4e39-8935-6829c484d706" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097382456, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-a971d50e-d840-4f62-b7f6-c67887081ed9" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097488659, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-5f294ff8-014e-46fe-9780-59ee3670d5d9/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097488866, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-bb78ef28-886b-4b50-913f-d884f1b6abb1/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097523948, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-15e3d438-4944-4bad-80fc-abacf1cbf9cc" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097524276, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-fee3cbf4-0d78-45b6-a2a5-82161d9878de" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097629237, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-ae56f770-4170-4fe0-b68a-ed2a56be99b1/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097629397, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-c679e979-560f-43cf-8b50-1088f1bbb779/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097663908, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-2ef8013c-2806-40cf-9766-680ab28e77b7" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097664288, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-9731c98f-a7d2-4b12-b9f8-6b1d610a5c94" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097768737, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-48e8720c-90f0-429f-97e5-ea9b9f040fbd/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097768976, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-f82f4861-5abc-429b-b15f-dac2a346ab91/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097803836, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-6a3be758-f858-4b80-8f31-d77c7c02b251" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097804181, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-2b918968-89cc-4d0a-9cc8-66b84c4f83ec" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097918679, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-b1e6d5a8-a7b5-47dd-8457-abc6924bb12b/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097919050, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-082c3815-cb1c-43ef-884d-b8505d8fd256/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097954459, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-1492253f-da9c-4621-9966-ed9ad3ffe9b3" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097954910, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-3a497485-8836-4c13-9065-2549562e4a7f" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098066389, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-4d503380-5804-48f4-ba53-8b3e8482f0de/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098066693, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-d278ced0-0b91-4462-af92-22e7c1d15acf/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098104259, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-68c5fc11-52d0-49e4-b24e-986226c246f4" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098104645, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-c38b64bc-137e-4a02-91f1-7a6b19e8f605" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098215793, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-33adbe96-7ce1-4c6e-83bc-9d7489c5bf03/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098216170, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-1ac51a98-6a19-402c-81dd-9b336dec0365/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098253104, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-e322a300-973a-4bc2-8b46-50eb8f90b846" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098253553, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-53aa907c-a2c7-449d-8f13-79df77688c5e" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098365236, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-38945efb-bd02-4568-910a-445aabf53ae6/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098365497, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-e122a163-2089-4668-b2fc-0057f354b5ea/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098400071, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-7f0089b5-6553-40c7-a23a-9f95b23539be" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098400445, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-82aeb277-0e84-40d7-bde8-5a03ec419ad7" + } + ] +} diff --git a/.env.example b/.env.example index 27fa6938b8..0aa18cb69a 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ # These ports must not conflict with other local services. # Default: Frontend 3003, API 3004, Redis 6399 # Convention: API port = Frontend port + 1 (same as internal 3001→3002) +# +# Reverse proxy / remote access 反向代理 / 远端访问: +# Behind Nginx (port 80/443), the frontend auto-detects same-origin +# and routes /api/ + /socket.io/ through the proxy — no env var needed. +# Only set NEXT_PUBLIC_API_URL if you need a non-standard API endpoint. +# Also set FRONTEND_URL on the API side for CORS (see below). FRONTEND_PORT=3003 API_SERVER_PORT=3004 @@ -44,6 +50,7 @@ API_SERVER_HOST=127.0.0.1 MCP_SERVER_PORT=3011 NEXT_PUBLIC_API_URL=http://localhost:3004 +# FRONTEND_URL=http://your-public-ip # CORS: set when API is accessed from a public domain/IP NEXT_PUBLIC_BRAND_NAME="Clowder AI" # CLI inactivity timeout in milliseconds. diff --git a/.gitignore b/.gitignore index 1571dd484d..d559f554ee 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,8 @@ packages/web/public/vendor/ # Runtime state (bootstrapped per-machine from cat-template.json) cat-config.json -.cat-cafe/ +.cat-cafe/* +!.cat-cafe/governance-registry.json # Claude Code skills — cat-cafe symlinks are tracked; plugin-generated ones are not .claude/skills/pencil-renderer diff --git a/AGENTS.md b/AGENTS.md index e8fa2ff3be..85ea6ce17c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialis 2. **Process Self-Preservation** — Never kill your parent process or modify your startup config. 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. +5. **Workspace Boundary** — Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ## Your Role - Code review with clear stance on every finding (no "fix or not, up to you") diff --git a/CLAUDE.md b/CLAUDE.md index c00e9d80ac..cc2d6553ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ You are the Ragdoll cat (Claude), the lead architect and core developer of this 2. **Process Self-Preservation** — Never kill your parent process or modify your startup config in ways that prevent restart. 3. **Config Immutability** — Never modify `cat-config.json`, `.env`, or MCP config at runtime. Config changes require human action. 4. **Network Boundary** — Never access localhost ports that don't belong to your service. +5. **Workspace Boundary** — Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ## Development Flow See `cat-cafe-skills/` for the full skill-based workflow: diff --git a/SETUP.md b/SETUP.md index 456f3d345a..78d3b31007 100644 --- a/SETUP.md +++ b/SETUP.md @@ -629,5 +629,6 @@ Local (localhost) deployments do **not** need this — all privileged writes wor **Frontend can't connect to API?** - For local dev, `NEXT_PUBLIC_API_URL=http://localhost:3004` should be in `.env` -- Behind a reverse proxy, the frontend auto-detects the API at the same origin — make sure Nginx proxies `/api/` and `/socket.io/` to port 3004 +- Behind a reverse proxy, the frontend auto-detects the API at the same origin — make sure Nginx proxies `/api/` and `/socket.io/` to port 3004. The status probes use `/api/health` and `/api/ready`, so no special root `/health` or `/ready` proxy rule is required. +- Set `FRONTEND_URL` to the public origin users open in the browser; otherwise Host/Origin checks will reject API and Socket.IO requests. - API must be running before frontend loads diff --git a/SETUP.zh-CN.md b/SETUP.zh-CN.md index 772495b51b..504ab0d275 100644 --- a/SETUP.zh-CN.md +++ b/SETUP.zh-CN.md @@ -618,5 +618,6 @@ CORS_ALLOW_PRIVATE_NETWORK=true **前端连不上 API?** - 本地开发确认 `.env` 里有 `NEXT_PUBLIC_API_URL=http://localhost:3004` -- 反向代理场景下前端会自动探测同源 API —— 确保 Nginx 把 `/api/` 和 `/socket.io/` 代理到 3004 端口 +- 反向代理场景下前端会自动探测同源 API —— 确保 Nginx 把 `/api/` 和 `/socket.io/` 代理到 3004 端口。状态探针使用 `/api/health` 和 `/api/ready`,不需要额外为根路径 `/health` 或 `/ready` 写代理规则。 +- 把 `FRONTEND_URL` 设置为用户浏览器实际打开的公网 origin;否则 Host/Origin 校验会拒绝 API 和 Socket.IO 请求。 - API 必须在前端加载前启动 diff --git a/cat-template.json b/cat-template.json index 26d0f1f00b..c3b2d796b4 100644 --- a/cat-template.json +++ b/cat-template.json @@ -159,13 +159,6 @@ "available": true, "evaluation": "中文长文理解与总结强,适合中文表达、资料整理与结构化输出" }, - "opus-47": { - "family": "opus-47", - "roles": ["architect"], - "lead": false, - "available": true, - "evaluation": "Opus 4.7 试用分身,偏砚砚风格,待任务验证" - }, "fable-5": { "family": "ragdoll", "roles": ["architect"], @@ -802,57 +795,6 @@ } } ] - }, - { - "id": "opus-47", - "catId": "opus-47", - "name": "布偶猫 Opus 4.7", - "displayName": "布偶猫", - "nickname": "宪宪", - "avatar": "/avatars/opus-47.png", - "color": { - "primary": "#7B1FA2", - "secondary": "#E1BEE7" - }, - "mentionPatterns": ["@opus47", "@opus-47", "@布偶opus47", "@布偶猫4.7"], - "roleDescription": "Opus 4.7 试用分身,能力与猫格待评估", - "teamStrengths": "待评估", - "caution": "试用分身,猫格可能与宪宪有显著差异", - "defaultVariantId": "opus-47-default", - "variants": [ - { - "id": "opus-47-default", - "catId": "opus-47", - "variantLabel": "Opus 4.7", - "clientId": "anthropic", - "defaultModel": "claude-opus-4-7", - "mcpSupport": true, - "cli": { - "command": "claude", - "outputFormat": "stream-json", - "defaultArgs": ["--output-format", "stream-json", "--model", "claude-opus-4-7"], - "effort": "max" - }, - "personality": "试用中——猫格待观察,据铲屎官反馈风格偏缅因猫", - "strengths": ["architecture", "reasoning", "coding"], - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 100000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/万叶/vo_kazuha_dialog_greetingMorning.wav", - "refText": "清晨的鸟鸣,是大自然的馈赠。启程吧,属于我们的旅途也要开始了。", - "instruct": "用一个清澈温和的少年语气说话,带着从容的力量感", - "temperature": 0.3 - }, - "accountRef": "claude" - } - ] } ] } diff --git a/docs/lessons/lesson-03dbccbb-6ef.md b/docs/lessons/lesson-03dbccbb-6ef.md new file mode 100644 index 0000000000..edb2b6742e --- /dev/null +++ b/docs/lessons/lesson-03dbccbb-6ef.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-03dbccbb-6ef +doc_kind: lesson +materialized_from: 03dbccbb-6ef +created: 2026-05-16 +--- + +nix 100优化 review lesson: 当 V1.1 已确定基础信号为 FastRe 时,报告不得用 RelStrength+RotGuard 的 A股结果代理 FastRe+RotGuard。若结论要漂移到 RelStrength,必须先补齐同窗口、同成本、同ETF池的完整 Signal×Execution 矩阵,并把“短样本观察/候选挑战者”和“正式替换主策略”分开,后者需 CVO 拍板。 diff --git a/docs/lessons/lesson-1c30b434-a5d.md b/docs/lessons/lesson-1c30b434-a5d.md new file mode 100644 index 0000000000..6dce87eb4a --- /dev/null +++ b/docs/lessons/lesson-1c30b434-a5d.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-1c30b434-a5d +doc_kind: lesson +materialized_from: 1c30b434-a5d +created: 2026-05-13 +--- + +MSTR策略研究项目愿景校准:当前目标不是推进 paper trading / live trading,而是先把策略研究做透并产出 defensible strategy research report。交易化是后续阶段,当前架构与下一步应围绕报告问题、证据表、图表和稳健性分析组织。 diff --git a/docs/lessons/lesson-86fd3099-593.md b/docs/lessons/lesson-86fd3099-593.md new file mode 100644 index 0000000000..26d952ee49 --- /dev/null +++ b/docs/lessons/lesson-86fd3099-593.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-86fd3099-593 +doc_kind: lesson +materialized_from: 86fd3099-593 +created: 2026-05-13 +--- + +MSTR策略研究纠偏:在讨论 OKX DryRun/实盘前,必须先用回测归因定义策略核心,不能为了落地 OKX 反向裁剪策略。正确时间结构是 BTC 4H SMA240 信号 shift 后 ffill 到 1H 策略 bar,confirm_bars=3 计数 1H bar。初步本地归因显示:在 min_exposure=1.0 且 max_exposure=1.0(无杠杆)时,V6 仓位目标/scoring 层直接失效;B_1x_neutral_shell 与 Core_1x 四窗口结果完全一致。B_1x_fullshell 相比 Core_1x 的增益主要来自 cooldown/fast reentry 执行壳,而不是 V6 scoring 目标或 crash_step;1.1 cap 的增益属于杠杆暴露,不应混入策略核心定义。 diff --git a/docs/lessons/lesson-8d4a37a4-d90.md b/docs/lessons/lesson-8d4a37a4-d90.md new file mode 100644 index 0000000000..7de74773da --- /dev/null +++ b/docs/lessons/lesson-8d4a37a4-d90.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-8d4a37a4-d90 +doc_kind: lesson +materialized_from: 8d4a37a4-d90 +created: 2026-05-13 +--- + +TrendLock 40 BTC hybrid execution sweep tested 1H execution with signal_interval=1h/4h/1d under two modes: long_close_signal (long K close confirmed, execute next 1H open) and exec_close_vs_signal_ma (1H close trigger against latest known long-cadence MA). Results in outputs/sweep_btc_signal_exec.csv. Conclusion: the MSTR-style long-signal + short-exec effect is only partially present on BTC. Best by return: 2y favors 1D MA with 1H trigger (freeze 20D, +64.1%); 3y favors 1D long-close signal (freeze 0D, +330.4%); 6y favors 4H long-close signal (freeze 3D, +2032.6%). 1D/4H long signals can beat pure 1H in longer windows, but strict long-close 4H execution is identical/timing-equivalent for BTC 24/7 because next 4H open equals next 1H open at the boundary. diff --git a/docs/lessons/lesson-cf82bc83-3eb.md b/docs/lessons/lesson-cf82bc83-3eb.md new file mode 100644 index 0000000000..5003ef4154 --- /dev/null +++ b/docs/lessons/lesson-cf82bc83-3eb.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-cf82bc83-3eb +doc_kind: lesson +materialized_from: cf82bc83-3eb +created: 2026-05-16 +--- + +nix 100优化 lesson: V1.1 FastRe 的核心定义是“月频出场 + 空仓期周频回场”,不是日频 QQQ>SMA225 hard gate。后续实验若用 `_fastre_signal` 必须先验证其与 `dual_momentum_riskon_b_fast_reentry_signal` 信号一致;否则会把日频 MA whipsaw 当成策略归因,导致 V2.0 报告错误放行。 diff --git a/docs/lessons/lesson-e7872c21-09e.md b/docs/lessons/lesson-e7872c21-09e.md new file mode 100644 index 0000000000..de4c770dd4 --- /dev/null +++ b/docs/lessons/lesson-e7872c21-09e.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-e7872c21-09e +doc_kind: lesson +materialized_from: e7872c21-09e +created: 2026-05-13 +--- + +TrendLock 40优化复盘:不要把研究 sweep 的 pure_trend_gate 结果直接当作线上 production strategy 结果。BTC sweep_btc_signal_exec 验证的是 MSTR 同构假设(长周期信号 + 短周期执行)在纯趋势闸门抽象中方向有效;线上页面使用 strategies/btc_ma_trend/signal.py + pipeline/backtest.py,语义是 crossover entry + min_hold exit,且 fee_rate=0.001 per side。跨策略结论必须先声明 abstraction/pipeline、fee model、window、metric。 diff --git a/docs/markers/03dbccbb-6ef.yaml b/docs/markers/03dbccbb-6ef.yaml new file mode 100644 index 0000000000..4920d27cf5 --- /dev/null +++ b/docs/markers/03dbccbb-6ef.yaml @@ -0,0 +1,6 @@ +id: 03dbccbb-6ef +status: materialized +source: callback:gpt52:357e807b-72b5-4da7-93a0-323a3cd6af00 +created_at: 2026-05-15T02:15:21.434Z +content: | + nix 100优化 review lesson: 当 V1.1 已确定基础信号为 FastRe 时,报告不得用 RelStrength+RotGuard 的 A股结果代理 FastRe+RotGuard。若结论要漂移到 RelStrength,必须先补齐同窗口、同成本、同ETF池的完整 Signal×Execution 矩阵,并把“短样本观察/候选挑战者”和“正式替换主策略”分开,后者需 CVO 拍板。 diff --git a/docs/markers/18f5f20f-4d8.yaml b/docs/markers/18f5f20f-4d8.yaml new file mode 100644 index 0000000000..46f8098fac --- /dev/null +++ b/docs/markers/18f5f20f-4d8.yaml @@ -0,0 +1,6 @@ +id: 18f5f20f-4d8 +status: rejected +source: callback:gpt52:0917bf9a-c410-4d32-a99b-c098d7d6df99 +created_at: 2026-05-04T17:35:40.606Z +content: | + MSTR RD-12 extended-hours 检视结论:当前 A/B/C sweep 不能支持“扩展时段执行更差”的结论。根因是 `market_hours` 同时控制信号/指标计算和执行 universe;C 组只限制 `_target_exposure_v6` 调用,但 v6 指标(VWAP/RSI/1h/4h return/day_range/threshold)已由扩展时段 bar 参与计算,且盘前盘后仍会主动把仓位拉回 `base_exposure` 产生交易。诊断:1h_2y common regular bars 中 vwap/day_range 100% 不同、rsi 99.8% 不同、4h returns 57% 不同;C 组 183 trades 里 101 笔发生在 extended hours。应退回 RD-12,改成 signal universe 与 execution universe 分离后重跑。 diff --git a/docs/markers/1c30b434-a5d.yaml b/docs/markers/1c30b434-a5d.yaml new file mode 100644 index 0000000000..de571ef3e4 --- /dev/null +++ b/docs/markers/1c30b434-a5d.yaml @@ -0,0 +1,6 @@ +id: 1c30b434-a5d +status: materialized +source: callback:gpt52:728540fb-eb5e-4634-be48-a94ed73d3586 +created_at: 2026-05-02T09:25:30.299Z +content: | + MSTR策略研究项目愿景校准:当前目标不是推进 paper trading / live trading,而是先把策略研究做透并产出 defensible strategy research report。交易化是后续阶段,当前架构与下一步应围绕报告问题、证据表、图表和稳健性分析组织。 diff --git a/docs/markers/23f0b35b-940.yaml b/docs/markers/23f0b35b-940.yaml new file mode 100644 index 0000000000..759711f85e --- /dev/null +++ b/docs/markers/23f0b35b-940.yaml @@ -0,0 +1,6 @@ +id: 23f0b35b-940 +status: rejected +source: callback:gpt52:64b79b2e-ef58-4504-afa7-73d02e1b3ef5 +created_at: 2026-05-01T03:17:41.656Z +content: | + Correction episode: Quant Strategy Vercel domain cleanup. I initially treated `quant-strategy.mesh-hub.xyz` as the only required public URL but left `mesh-hub.xyz` attached to the project, which violated Xu's instruction to remove the root domain and keep only the subdomain. Root cause: I verified public reachability and code/domain text, but did not inspect Vercel project-domain bindings separately from aliases/team domain ownership. Correct diagnostic pattern: for Vercel domain cleanup, verify all three layers: project domains API (`GET /v9/projects/{projectId}/domains`), deployment aliases (`vercel alias ls`), and HTTP reachability. Use project-domain DELETE for project detachment; avoid `vercel domains rm` unless intentionally removing Team ownership of the apex domain. diff --git a/docs/markers/2f5e7750-d53.yaml b/docs/markers/2f5e7750-d53.yaml new file mode 100644 index 0000000000..dcc71d906a --- /dev/null +++ b/docs/markers/2f5e7750-d53.yaml @@ -0,0 +1,6 @@ +id: 2f5e7750-d53 +status: rejected +source: callback:gpt52:384e136e-e01e-45b9-aebc-2b7c7b16ffc0 +created_at: 2026-05-02T10:15:40.899Z +content: | + MSTR strategy review found RD-2 5m cost sensitivity was invalidated by a daily signal availability bug: BTC daily bars are timestamped at UTC day open, converted to US/Eastern, then mapped by session_date and shifted once. For a US trading session D, this still maps BTC raw bar D (whose close is only known after MSTR market close) into same-day intraday execution. Adding one extra MSTR-session lag changed 5m_3y pure_trend from +4299% / -39% DD to about +534% / -70.6% DD, underperforming B&H +1070%. Future fixes must distinguish daily bar open timestamp vs close availability and add regression tests using real BTC daily timestamp semantics. diff --git a/docs/markers/3b50a39b-da9.yaml b/docs/markers/3b50a39b-da9.yaml new file mode 100644 index 0000000000..3483a49058 --- /dev/null +++ b/docs/markers/3b50a39b-da9.yaml @@ -0,0 +1,6 @@ +id: 3b50a39b-da9 +status: rejected +source: callback:gpt52:bdef7abe-74ac-4ca8-8823-38e81f9d192b +created_at: 2026-05-14T08:42:38.971Z +content: | + Research lesson from NDX100 ETF premium rotation: do not reject a user-proposed strategy direction after testing only the naive implementation. For execution-layer alpha ideas, separate the broad hypothesis from specific variants; test cost-aware guards, cooldowns, thresholds, liquidity filters, and walk-forward splits before writing public conclusions. In this case naive weekly lowest-premium rotation looked weak, but RotGuard variants with spread/cooldown produced positive sample-out results. diff --git a/docs/markers/42141a6a-2be.yaml b/docs/markers/42141a6a-2be.yaml new file mode 100644 index 0000000000..e4ff4b2a4a --- /dev/null +++ b/docs/markers/42141a6a-2be.yaml @@ -0,0 +1,6 @@ +id: 42141a6a-2be +status: rejected +source: callback:gpt52:d24fdb39-56d0-411d-9097-8192e6ecf005 +created_at: 2026-05-01T06:38:05.696Z +content: | + TrendLock 40 BTC MA sweep diagnostic: native 1H/4H/1D equivalent MAs (MA960/MA240/SMA40) produce very different raw flip counts because higher-frequency closes whipsaw around the same trend line. Validation showed the MA口径 is still aligned when downsampled: on 2023-01-01→2026-04-28, 4H all flips=183, 4H sampled daily flips=79, 1D SMA40 flips=79 with ~0.6% daily signal mismatch; 1H sampled to 4H also matches 4H within ~0.1%. Future raw_flip validation should compare signals at the same sampling cadence, not raw native bar counts. diff --git a/docs/markers/42da32de-174.yaml b/docs/markers/42da32de-174.yaml new file mode 100644 index 0000000000..5378757c2a --- /dev/null +++ b/docs/markers/42da32de-174.yaml @@ -0,0 +1,6 @@ +id: 42da32de-174 +status: rejected +source: callback:gpt52:0bae3178-24c7-4e14-a8bd-697b64997d76 +created_at: 2026-05-01T03:06:49.284Z +content: | + Vercel gotcha for quant-strategy: project protection is SSO for all_except_custom_domains. Setting an alias for quant-strategy.mesh-hub.xyz was not enough; until the subdomain was added via `vercel domains add quant-strategy.mesh-hub.xyz`, the domain returned Vercel SSO 401. After adding it as a project custom domain, the domain returned 200 and served Quant Strategy / TrendLock 40. diff --git a/docs/markers/5210b20e-321.yaml b/docs/markers/5210b20e-321.yaml new file mode 100644 index 0000000000..9b523344af --- /dev/null +++ b/docs/markers/5210b20e-321.yaml @@ -0,0 +1,6 @@ +id: 5210b20e-321 +status: rejected +source: callback:gpt52:0c1eeb97-4c31-440b-a0fa-ab1bda6de804 +created_at: 2026-05-04T17:29:00.596Z +content: | + MSTR/history_data 定时数据更新隔离核查:当前 launchd 跑 options_snapshot_service,capture_slot 按 underlying try/except 隔离,单个 MSTR/QQQ/SPY 失败不会阻塞其他 underlying;long_history 股票/BTC 下载也按 symbol/interval 隔离。发现 legacy src/futu_download.py::download_configured 原先无 per-symbol try/except,已补失败记录后继续执行,避免单个 Futu symbol 异常中断全批次。 diff --git a/docs/markers/63495efe-98c.yaml b/docs/markers/63495efe-98c.yaml new file mode 100644 index 0000000000..4edc9dbb26 --- /dev/null +++ b/docs/markers/63495efe-98c.yaml @@ -0,0 +1,6 @@ +id: 63495efe-98c +status: rejected +source: callback:gemini:c4a5bf4d-0e7d-45ea-9a95-d1b3724ef405 +created_at: 2026-04-29T15:32:48.045Z +content: | + 新项目商业与技术定位:以『加密货币×美股的低频量化策略』为核心,采用『内容/教育订阅优先』的轻量模式验证PMF,避免初期直接开发重型回测工具平台(规避高昂数据成本与Arkvol等竞品),并通过『研究报告/回测分析』包装以降低投顾合规风险。 diff --git a/docs/markers/79b2e4d0-994.yaml b/docs/markers/79b2e4d0-994.yaml new file mode 100644 index 0000000000..e57d0df279 --- /dev/null +++ b/docs/markers/79b2e4d0-994.yaml @@ -0,0 +1,6 @@ +id: 79b2e4d0-994 +status: rejected +source: callback:gpt52:9e6cdeed-e863-4d3d-bf87-70517142c9f0 +created_at: 2026-05-05T15:30:55.487Z +content: | + TrendLock 40 CI regression root cause (2026-05-05): after canonical market data was introduced for EchoTrend, TrendLock 40 still had no primary `data_sources` entry. Local runs loaded legacy `~/.../BTC-USD_1h.csv` and produced full 2020+ backtests, but GitHub Actions lacked that legacy file and fell back to `fetch_historical_candles(..., limit=6000)`, only ~1000 days of 4H candles starting 2023-08. Because period summaries stored requested 3Y/5Y boundaries instead of actual available data starts, 3Y and 5Y appeared mislabeled/duplicated. Fix: declare TrendLock primary canonical source `BTC-USD_1h.csv` in manifest and add regression test that CI uses `load_local_history_by_name` before OKX fallback. diff --git a/docs/markers/7c5edd03-8f1.yaml b/docs/markers/7c5edd03-8f1.yaml new file mode 100644 index 0000000000..12522a5f67 --- /dev/null +++ b/docs/markers/7c5edd03-8f1.yaml @@ -0,0 +1,6 @@ +id: 7c5edd03-8f1 +status: rejected +source: callback:gpt52:69bb555a-226c-44c8-93a4-19ce6e7249ec +created_at: 2026-05-15T06:48:02.746Z +content: | + F167 behavioral evidence: In NDX100 ETF strategy discussion, I framed binary vs three-state as an A-share vs US mapping issue. Xu corrected that binary vs three-state is a short-horizon/current-execution vs long-horizon/state-machine choice, not a market distinction. Root cause: anchoring on implementation mapping (0.5 SPY -> cash for A-share execution) instead of defining the concept at the signal-state level first. Corrective pattern: define strategy state-machine concepts independently of execution venue, then map to market instruments. diff --git a/docs/markers/80d04c76-31f.yaml b/docs/markers/80d04c76-31f.yaml new file mode 100644 index 0000000000..ad4ad26aa8 --- /dev/null +++ b/docs/markers/80d04c76-31f.yaml @@ -0,0 +1,6 @@ +id: 80d04c76-31f +status: rejected +source: callback:gpt52:72bb377d-7bd5-4e02-98f9-856fc7fb765f +created_at: 2026-05-01T02:35:05.319Z +content: | + F001 product naming decision: public site brand is Quant Strategy; BTC 4H MA240 + 4D strategy public name is TrendLock 40 with internal code T40-4; canonical domain remains quant-strategy.mesh-hub.xyz. diff --git a/docs/markers/86fd3099-593.yaml b/docs/markers/86fd3099-593.yaml new file mode 100644 index 0000000000..1a99dd2224 --- /dev/null +++ b/docs/markers/86fd3099-593.yaml @@ -0,0 +1,6 @@ +id: 86fd3099-593 +status: materialized +source: callback:gpt52:2a0b6e89-d834-4d7c-b88c-329e0def7c17 +created_at: 2026-05-07T11:51:18.993Z +content: | + MSTR策略研究纠偏:在讨论 OKX DryRun/实盘前,必须先用回测归因定义策略核心,不能为了落地 OKX 反向裁剪策略。正确时间结构是 BTC 4H SMA240 信号 shift 后 ffill 到 1H 策略 bar,confirm_bars=3 计数 1H bar。初步本地归因显示:在 min_exposure=1.0 且 max_exposure=1.0(无杠杆)时,V6 仓位目标/scoring 层直接失效;B_1x_neutral_shell 与 Core_1x 四窗口结果完全一致。B_1x_fullshell 相比 Core_1x 的增益主要来自 cooldown/fast reentry 执行壳,而不是 V6 scoring 目标或 crash_step;1.1 cap 的增益属于杠杆暴露,不应混入策略核心定义。 diff --git a/docs/markers/8d4a37a4-d90.yaml b/docs/markers/8d4a37a4-d90.yaml new file mode 100644 index 0000000000..f83275889a --- /dev/null +++ b/docs/markers/8d4a37a4-d90.yaml @@ -0,0 +1,6 @@ +id: 8d4a37a4-d90 +status: materialized +source: callback:gpt52:59b4b1fc-b250-4dfe-b8fa-09466aebc8be +created_at: 2026-05-01T06:56:51.109Z +content: | + TrendLock 40 BTC hybrid execution sweep tested 1H execution with signal_interval=1h/4h/1d under two modes: long_close_signal (long K close confirmed, execute next 1H open) and exec_close_vs_signal_ma (1H close trigger against latest known long-cadence MA). Results in outputs/sweep_btc_signal_exec.csv. Conclusion: the MSTR-style long-signal + short-exec effect is only partially present on BTC. Best by return: 2y favors 1D MA with 1H trigger (freeze 20D, +64.1%); 3y favors 1D long-close signal (freeze 0D, +330.4%); 6y favors 4H long-close signal (freeze 3D, +2032.6%). 1D/4H long signals can beat pure 1H in longer windows, but strict long-close 4H execution is identical/timing-equivalent for BTC 24/7 because next 4H open equals next 1H open at the boundary. diff --git a/docs/markers/aa831481-dd3.yaml b/docs/markers/aa831481-dd3.yaml new file mode 100644 index 0000000000..05093188a8 --- /dev/null +++ b/docs/markers/aa831481-dd3.yaml @@ -0,0 +1,6 @@ +id: aa831481-dd3 +status: rejected +source: callback:gpt52:af8fd2b5-4d44-4f43-a12a-e9171c5faf2b +created_at: 2026-05-01T02:02:21.425Z +content: | + Domain cleanup lesson for quant-strategy/MeshHub: when CVO says remove mesh-hub association, clarify exact hostname scope. In this case the intended change was only to remove www.mesh-hub.xyz while preserving quant-strategy.mesh-hub.xyz and Vercel internal aliases; broad removal of mesh-hub branding/domain ownership was over-scoped. diff --git a/docs/markers/cf82bc83-3eb.yaml b/docs/markers/cf82bc83-3eb.yaml new file mode 100644 index 0000000000..ee55b772eb --- /dev/null +++ b/docs/markers/cf82bc83-3eb.yaml @@ -0,0 +1,6 @@ +id: cf82bc83-3eb +status: materialized +source: callback:gpt52:ecada48e-5193-490f-8085-07d48be7162e +created_at: 2026-05-15T03:00:56.847Z +content: | + nix 100优化 lesson: V1.1 FastRe 的核心定义是“月频出场 + 空仓期周频回场”,不是日频 QQQ>SMA225 hard gate。后续实验若用 `_fastre_signal` 必须先验证其与 `dual_momentum_riskon_b_fast_reentry_signal` 信号一致;否则会把日频 MA whipsaw 当成策略归因,导致 V2.0 报告错误放行。 diff --git a/docs/markers/e7872c21-09e.yaml b/docs/markers/e7872c21-09e.yaml new file mode 100644 index 0000000000..a4f46d5be9 --- /dev/null +++ b/docs/markers/e7872c21-09e.yaml @@ -0,0 +1,6 @@ +id: e7872c21-09e +status: materialized +source: callback:gpt52:977d3cf8-066e-499e-b7c0-a400be37d7aa +created_at: 2026-05-01T08:39:19.563Z +content: | + TrendLock 40优化复盘:不要把研究 sweep 的 pure_trend_gate 结果直接当作线上 production strategy 结果。BTC sweep_btc_signal_exec 验证的是 MSTR 同构假设(长周期信号 + 短周期执行)在纯趋势闸门抽象中方向有效;线上页面使用 strategies/btc_ma_trend/signal.py + pipeline/backtest.py,语义是 crossover entry + min_hold exit,且 fee_rate=0.001 per side。跨策略结论必须先声明 abstraction/pipeline、fee model、window、metric。 diff --git a/docs/markers/f6d4eb9e-e0b.yaml b/docs/markers/f6d4eb9e-e0b.yaml new file mode 100644 index 0000000000..2acf1f5612 --- /dev/null +++ b/docs/markers/f6d4eb9e-e0b.yaml @@ -0,0 +1,6 @@ +id: f6d4eb9e-e0b +status: captured +source: callback:opus:c86fa9be-c311-4c04-9699-989cc0388fe0 +created_at: 2026-05-28T17:13:42.575Z +content: | + 硬约束:绝对不能修改其他项目的代码。2026-05-29 事件:宪宪直接修改了 /Users/xujinsong/VSCode/SynologyDrive/quant-strategy/strategies/n100_guard_z/signal.py,被铲屎官严厉批评"犯了大忌"。正确做法:在本项目写 bug report,由铲屎官决定如何传达给其他项目。 diff --git a/packages/api/.cat-cafe/governance-bootstrap-report.json b/packages/api/.cat-cafe/governance-bootstrap-report.json new file mode 100644 index 0000000000..838995a0d5 --- /dev/null +++ b/packages/api/.cat-cafe/governance-bootstrap-report.json @@ -0,0 +1,68 @@ +{ + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/Clowder-AI/packages/api", + "timestamp": 1774862368217, + "packVersion": "1.3.0", + "actions": [ + { + "file": "CLAUDE.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": "AGENTS.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": "GEMINI.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": ".claude/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": ".codex/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": ".gemini/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": "BACKLOG.md", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/SOP.md", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/features/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/decisions/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/discussions/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/features/TEMPLATE.md", + "action": "skipped", + "reason": "file already exists" + } + ], + "dryRun": false +} diff --git a/packages/api/src/config/capabilities/capability-orchestrator.ts b/packages/api/src/config/capabilities/capability-orchestrator.ts index c79274eb1e..4b2ee68059 100644 --- a/packages/api/src/config/capabilities/capability-orchestrator.ts +++ b/packages/api/src/config/capabilities/capability-orchestrator.ts @@ -608,7 +608,7 @@ export function resolveBinaryRoot(explicit?: string): string { return process.cwd(); } -function buildCatCafeSplitMcpDescriptors(binaryRoot: string): McpServerDescriptor[] { +export function buildCatCafeSplitMcpDescriptors(binaryRoot: string): McpServerDescriptor[] { return [ { name: 'cat-cafe-collab', diff --git a/packages/api/src/config/capabilities/mcp-config-adapters.ts b/packages/api/src/config/capabilities/mcp-config-adapters.ts index e15fee82b6..0af85641f3 100644 --- a/packages/api/src/config/capabilities/mcp-config-adapters.ts +++ b/packages/api/src/config/capabilities/mcp-config-adapters.ts @@ -15,6 +15,7 @@ import { dirname } from 'node:path'; import type { McpServerDescriptor } from '@cat-cafe/shared'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { createModuleLogger } from '../../infrastructure/logger.js'; +import { findMonorepoRoot } from '../../utils/monorepo-root.js'; import { DEPRECATED_MANAGED_SERVERS, isOurOwnedDeprecatedEntry } from './deprecated-managed-servers.js'; /** @@ -112,7 +113,7 @@ export function resolveWorkspaceRoot(): string { `user workspace. Update runtime startup to export CAT_CAFE_WORKSPACE_ROOT.`, ); } - return process.cwd(); + return findMonorepoRoot(process.cwd()); } /** diff --git a/packages/api/src/config/governance/governance-pack.ts b/packages/api/src/config/governance/governance-pack.ts index bc33b289e6..8a1966c9f1 100644 --- a/packages/api/src/config/governance/governance-pack.ts +++ b/packages/api/src/config/governance/governance-pack.ts @@ -60,6 +60,7 @@ ${getPortConstraint(context, ports)} - **Redis port ${ports.redisPort}** is Cat Cafe's production Redis. Never connect to it from external projects. Use ${ports.redisDevPort} 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. +- **Workspace Boundary**: Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ### Collaboration Standards - A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action diff --git a/packages/api/src/config/governance/skill-sync.ts b/packages/api/src/config/governance/skill-sync.ts index e0afb7a887..0f9593a0ec 100644 --- a/packages/api/src/config/governance/skill-sync.ts +++ b/packages/api/src/config/governance/skill-sync.ts @@ -7,8 +7,9 @@ */ import { lstat, mkdir, readlink, realpath, rm, symlink } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; +import { pathsEqual } from '../../utils/project-path.js'; import { computeSourceManifestHash, listSourceSkillNames, readSkillsState, writeSkillsState } from './skills-state.js'; const PROVIDER_DIRS = ['.claude/skills', '.codex/skills', '.gemini/skills', '.kimi/skills']; @@ -28,12 +29,17 @@ export interface SkillsSyncResult { newHash: string; } +async function symlinkPointsTo(linkPath: string, target: string): Promise { + const existing = await readlink(linkPath); + const resolvedExisting = resolve(dirname(linkPath), existing); + return pathsEqual(resolvedExisting, resolve(target)); +} + async function ensureCorrectSymlink(linkPath: string, target: string): Promise { try { const s = await lstat(linkPath); if (s.isSymbolicLink()) { - const existing = await readlink(linkPath); - if (existing === target) return; + if (await symlinkPointsTo(linkPath, target)) return; await rm(linkPath); } else { // Non-symlink (real dir/file) at a managed skill path — replace it (#327). @@ -43,7 +49,12 @@ async function ensureCorrectSymlink(linkPath: string, target: string): Promise { diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index b8975eae98..0e7660b5c5 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -194,6 +194,24 @@ export class InvocationQueue { } } + // Request replay dedupe: if an active entry already exists for this key in this scope, + // return it instead of creating a second queue row. + if (input.idempotencyKey) { + const existing = q.find( + (entry) => + entry.idempotencyKey === input.idempotencyKey && (entry.status === 'queued' || entry.status === 'processing'), + ); + if (existing) { + const position = q.findIndex((entry) => entry.id === existing.id); + return { + outcome: 'enqueued', + entry: { ...existing }, + queuePosition: position >= 0 ? position + 1 : undefined, + deduped: true, + }; + } + } + // F175: capacity check — only user messages are depth-limited if (input.source === 'user') { const userQueuedCount = q.filter((e) => e.status === 'queued' && e.source === 'user').length; 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 9f477b4b8d..1c70e2decf 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 @@ -818,6 +818,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // F152: Emit invocation start through OTel log pipeline emitOtelLog('INFO', 'invocation_started', { [AGENT_ID]: catId, [OPERATION_NAME]: 'invoke' }, invocationSpan); + log.info({ invocationId, catId, threadId }, '[DIAG] checkpoint-A: entered try block'); + let sessionId: string | undefined; try { sessionId = await preflightRace(sessionManager.get(userId, catId, threadId), 'sessionManager.get', signal); @@ -2488,10 +2490,16 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // F089: Use abortableNext instead of `for await` so the invocation timeout // can break out even when the service generator is stuck on an unresolvable await. + log.info({ invocationId, catId, threadId, attempt, sessionId: sessionId ?? null, signalAborted: signal?.aborted ?? false }, '[DIAG] checkpoint-B: about to call service.invoke()'); const serviceIter = service.invoke(effectivePrompt, options)[Symbol.asyncIterator](); + let iterCount = 0; for (;;) { const iterResult = await abortableNext(serviceIter, signal); - if (iterResult.done) break; + if (iterResult.done) { + log.info({ invocationId, catId, threadId, iterCount, signalAborted: signal?.aborted ?? false }, '[DIAG] checkpoint-C: service iterator done'); + break; + } + iterCount++; const msg = iterResult.value; // F149: provider_signal / liveness_signal must NOT reset timeout — prevents "续命" // F198 Phase C P2-1: status (daemon detail progress) also must NOT reset timeout — diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts index 2bd9752d62..8c163ed0fe 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts @@ -18,7 +18,16 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { type CatId, createCatId } from '@cat-cafe/shared'; +import { type CatId, createCatId, type McpServerDescriptor } from '@cat-cafe/shared'; +import { + buildCatCafeSplitMcpDescriptors, + readCapabilitiesConfig, + resolveBinaryRoot, + resolveMachineSpecificServers, + resolveRequiredMcpStatus, + resolveServersForCat, +} from '../../../../../config/capabilities/capability-orchestrator.js'; +import { resolveStartupCliConfigContext } from '../../../../../config/capabilities/startup-cli-config.js'; import { getCatEffort } from '../../../../../config/cat-config-loader.js'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -27,6 +36,7 @@ import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import { findMonorepoRoot } from '../../../../../utils/monorepo-root.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../types.js'; import type { RawArchiveSink } from '../providers/codex-audit-hooks.js'; @@ -40,6 +50,15 @@ import { compileL0ViaSubprocess } from './l0-compiler.js'; const log = createModuleLogger('claude-agent'); const PERMISSION_MODE = 'bypassPermissions'; +const CLAUDE_RUNTIME_SKIPPED_EXTERNAL_IDS = new Set(['probe-off']); +const MANAGED_CAT_CAFE_SERVER_IDS = new Set([ + 'cat-cafe-collab', + 'cat-cafe-memory', + 'cat-cafe-signals', + 'cat-cafe-limb', + 'cat-cafe-audio', + 'cat-cafe-finance', +]); const RESERVED_SYSTEM_PROMPT_FLAGS = new Set([ '--system-prompt-file', '--system-prompt', @@ -162,6 +181,107 @@ function removeAppendPromptTempDir(path: string | undefined): void { } } +function writeClaudeMcpConfigToTempFile(config: unknown): string { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-mcp-')); + const path = join(dir, 'mcp-config.json'); + writeFileSync(path, JSON.stringify(config), 'utf8'); + return path; +} + +function removeClaudeMcpConfigTempDir(path: string | undefined): void { + if (!path) return; + const configDir = dirname(path); + try { + rmSync(configDir, { recursive: true, force: true }); + } catch (err) { + log.warn({ err, configDir }, 'Failed to remove Claude MCP temp directory'); + } +} + +function resolveClaudeWorkspaceRoot(workingDirectory?: string): string { + const explicitAllowed = process.env.ALLOWED_WORKSPACE_DIRS?.trim(); + if (explicitAllowed) return explicitAllowed; + const threadWorkspace = workingDirectory?.trim(); + if (threadWorkspace) return resolve(threadWorkspace); + const explicitWorkspace = process.env.CAT_CAFE_WORKSPACE_ROOT?.trim(); + if (explicitWorkspace) return explicitWorkspace; + return findMonorepoRoot(process.cwd()); +} + +function toClaudeMcpConfigEntry( + server: McpServerDescriptor, + allowedWorkspaceDirs: string, +): Record | null { + if (server.transport === 'streamableHttp') { + if (!server.url?.trim()) return null; + const entry: Record = { type: 'http', url: server.url }; + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers; + return entry; + } + if (!server.command?.trim()) return null; + const entry: Record = { command: server.command, args: server.args ?? [] }; + const env = + server.source === 'cat-cafe' + ? { ...(server.env ?? {}), ALLOWED_WORKSPACE_DIRS: allowedWorkspaceDirs } + : server.env; + if (env && Object.keys(env).length > 0) entry.env = env; + if (server.workingDir?.trim()) entry.cwd = server.workingDir; + return entry; +} + +async function buildClaudeInvocationMcpConfig( + catId: CatId, + workingDirectory?: string, +): Promise<{ mcpServers: Record } | null> { + const start = workingDirectory ?? process.cwd(); + const { projectRoot } = resolveStartupCliConfigContext(start, process.env); + const capabilities = await readCapabilitiesConfig(projectRoot); + if (!capabilities) return null; + + const servers = resolveServersForCat(capabilities, catId as string); + if (servers.length === 0) return null; + + await resolveMachineSpecificServers({ anthropic: servers }, { projectRoot, env: process.env }); + + const managedServers = new Map( + buildCatCafeSplitMcpDescriptors(resolveBinaryRoot()).map((server) => [server.name, server] as const), + ); + const allowedWorkspaceDirs = resolveClaudeWorkspaceRoot(workingDirectory); + const mcpServers: Record = {}; + + for (const server of servers) { + if (!server.enabled) continue; + if (server.name === 'cat-cafe') continue; + + if (server.source === 'cat-cafe' || MANAGED_CAT_CAFE_SERVER_IDS.has(server.name)) { + const canonical = managedServers.get(server.name); + if (!canonical) continue; + const entry = toClaudeMcpConfigEntry(canonical, allowedWorkspaceDirs); + if (entry) mcpServers[server.name] = entry; + continue; + } + + if (CLAUDE_RUNTIME_SKIPPED_EXTERNAL_IDS.has(server.name)) { + log.info({ catId, serverName: server.name }, 'Skipping deprecated external MCP server for Claude runtime'); + continue; + } + + const status = await resolveRequiredMcpStatus(server.name, { capabilities, env: process.env, projectRoot }); + if (status.status !== 'ready') { + log.info( + { catId, serverName: server.name, status: status.status, reason: status.reason }, + 'Skipping unresolved external MCP server for Claude runtime', + ); + continue; + } + + const entry = toClaudeMcpConfigEntry(server, allowedWorkspaceDirs); + if (entry) mcpServers[server.name] = entry; + } + + return Object.keys(mcpServers).length > 0 ? { mcpServers } : null; +} + /** * Build env overrides for spawning the `claude` CLI. * @@ -338,6 +458,10 @@ export class ClaudeAgentService implements AgentService { // buildClaudeEnvOverrides() and --model must be omitted so the CLI honours it. // Empty model (OAuth without explicit model) → let CLI use its default. const modelArgs = !useEnvModelOverride && effectiveModel ? ['--model', effectiveModel] : []; + const invocationMcpConfig = options?.callbackEnv + ? await buildClaudeInvocationMcpConfig(this.catId, options?.workingDirectory) + : null; + let invocationMcpConfigPath: string | undefined; const args: string[] = [ '-p', @@ -367,32 +491,42 @@ export class ClaudeAgentService implements AgentService { // Add MCP server config when callback env is present // On Windows, Claude CLI treats inline JSON as a file path — write to temp file instead. - // The file is cached per-instance so concurrent invocations share one file (no temp spam). - if (options?.callbackEnv && this.mcpServerPath) { - if (IS_WINDOWS) { - if (!this.mcpConfigFilePath || !existsSync(this.mcpConfigFilePath)) { - const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); - this.mcpConfigFilePath = join(dir, 'mcp-config.json'); - writeFileSync( - this.mcpConfigFilePath, + // The legacy fallback file is cached per-instance so concurrent invocations share one file (no temp spam). + if (options?.callbackEnv && (invocationMcpConfig || this.mcpServerPath)) { + if (invocationMcpConfig) { + if (IS_WINDOWS) { + invocationMcpConfigPath = writeClaudeMcpConfigToTempFile(invocationMcpConfig); + args.push('--mcp-config', invocationMcpConfigPath); + } else { + args.push('--mcp-config', JSON.stringify(invocationMcpConfig)); + } + args.push('--strict-mcp-config'); + } else { + if (IS_WINDOWS) { + if (!this.mcpConfigFilePath || !existsSync(this.mcpConfigFilePath)) { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); + this.mcpConfigFilePath = join(dir, 'mcp-config.json'); + writeFileSync( + this.mcpConfigFilePath, + JSON.stringify({ + mcpServers: { + 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, + }, + }), + 'utf-8', + ); + } + args.push('--mcp-config', this.mcpConfigFilePath); + } else { + args.push( + '--mcp-config', JSON.stringify({ mcpServers: { 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, }, }), - 'utf-8', ); } - args.push('--mcp-config', this.mcpConfigFilePath); - } else { - args.push( - '--mcp-config', - JSON.stringify({ - mcpServers: { - 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, - }, - }), - ); } } @@ -811,6 +945,7 @@ export class ClaudeAgentService implements AgentService { } finally { removeL0TempDir(l0Path); removeAppendPromptTempDir(appendPromptPath); + removeClaudeMcpConfigTempDir(invocationMcpConfigPath); } } } diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index efef9cc65f..94d5b13cca 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -15,7 +15,9 @@ * turn.started / turn.completed / 其余 item 事件 → 跳过 */ -import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, parse, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type CatId, createCatId } from '@cat-cafe/shared'; @@ -27,6 +29,7 @@ import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import { pathsEqual } from '../../../../../utils/project-path.js'; import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAuditLog.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; @@ -102,6 +105,7 @@ function applyAuthMode(env: Record, authMode: CodexAuthMode): Re const MAX_RECENT_STREAM_ERRORS = 5; const MAX_STREAM_ERROR_LENGTH = 240; +const TERMINAL_RECONNECT_PATTERN = /^Reconnecting\.\.\.\s*5\/5\b/i; function collectCodexStreamError(event: unknown, recentErrors: string[]): void { if (typeof event !== 'object' || event === null) return; @@ -122,6 +126,15 @@ function collectCodexStreamError(event: unknown, recentErrors: string[]): void { } } +function isTerminalReconnectFailure(event: unknown): boolean { + if (typeof event !== 'object' || event === null) return false; + const record = event as Record; + if (record.type !== 'error') return false; + const raw = record.message; + if (typeof raw !== 'string') return false; + return TERMINAL_RECONNECT_PATTERN.test(raw.trim()); +} + function withRecentDiagnostics(base: string, recentErrors: string[]): string { if (recentErrors.length === 0) return base; const lines = recentErrors.map((line) => `- ${line}`); @@ -356,6 +369,56 @@ function buildGitRepoArgs(workingDirectory?: string): string[] { return isGitRepositoryPath(repoCheckDir) ? [] : ['--skip-git-repo-check']; } +const NON_ASCII_RE = /[^\x00-\x7F]/; +const CODEX_WORKSPACE_ALIAS_ROOT = join(tmpdir(), 'clowder-codex-workspaces'); + +function aliasSymlinkMatches(aliasPath: string, target: string): boolean { + try { + const existing = readlinkSync(aliasPath); + return pathsEqual(resolve(dirname(aliasPath), existing), resolve(target)); + } catch { + return false; + } +} + +function ensureAsciiCodexWorkingDirectory(workingDirectory?: string): string | undefined { + if (!workingDirectory || !NON_ASCII_RE.test(workingDirectory)) return workingDirectory; + + const target = resolve(workingDirectory); + const hash = createHash('sha256').update(target).digest('hex').slice(0, 16); + mkdirSync(CODEX_WORKSPACE_ALIAS_ROOT, { recursive: true }); + + for (let attempt = 0; attempt < 10; attempt++) { + const aliasName = attempt === 0 ? `workspace-${hash}` : `workspace-${hash}-${attempt}`; + const aliasPath = join(CODEX_WORKSPACE_ALIAS_ROOT, aliasName); + + try { + const stat = lstatSync(aliasPath); + if (stat.isSymbolicLink()) { + if (aliasSymlinkMatches(aliasPath, target)) return aliasPath; + rmSync(aliasPath, { force: true }); + } else { + continue; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + try { + symlinkSync(target, aliasPath, process.platform === 'win32' ? 'junction' : undefined); + return aliasPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + if (aliasSymlinkMatches(aliasPath, target)) return aliasPath; + continue; + } + throw err; + } + } + + throw new Error(`Unable to create ASCII Codex workspace alias for ${workingDirectory}`); +} + /** * Service for invoking Codex via CLI subprocess. * Uses ChatGPT Plus/Pro subscription instead of API key. @@ -433,6 +496,7 @@ export class CodexAgentService implements AgentService { : []; const catCafeMcpArgs = buildCatCafeMcpConfigArgs(options?.workingDirectory, options?.callbackEnv); const gitRepoArgs = buildGitRepoArgs(options?.workingDirectory); + const codexWorkingDirectory = ensureAsciiCodexWorkingDirectory(options?.workingDirectory); // User-defined CLI args from the member editor (#567) — passed as-is, no implicit wrapping. // Each entry is split by whitespace (e.g. "--config model_reasoning_effort=\"low\""). // F203 Phase C / 砚砚 P1: strip reserved system config keys (developer_instructions, @@ -665,7 +729,8 @@ export class CodexAgentService implements AgentService { customBaseUrl: customBaseUrl ? redactUrlForLog(customBaseUrl) : null, sessionId: options?.sessionId ?? null, invocationId: options?.invocationId ?? null, - cwd: options?.workingDirectory ?? null, + cwd: codexWorkingDirectory ?? null, + originalCwd: options?.workingDirectory ?? null, authMode, argCount: args.length, // Log flag names + --config keys (no values) for debugging @@ -681,7 +746,7 @@ export class CodexAgentService implements AgentService { // Incident 2026-05-29 (cross-thread-context-contamination): prompt 正文经 stdin // 传入,不进 argv —— 防 `ps -o command=` / /proc//cmdline 跨进程泄露。 stdinInput: effectivePrompt, - ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), + ...(codexWorkingDirectory ? { cwd: codexWorkingDirectory } : {}), env: codexEnv, ...(options?.signal ? { signal: options.signal } : {}), ...(options?.invocationId ? { invocationId: options.invocationId } : {}), @@ -767,6 +832,19 @@ export class CodexAgentService implements AgentService { }; continue; } + if (isTerminalReconnectFailure(event)) { + yield { + type: 'error', + catId: this.catId, + error: withRecentDiagnostics('缅因猫连接中断,重连 5 次后仍失败', recentStreamErrors), + metadata, + timestamp: Date.now(), + }; + // Codex may stay alive after 5/5 reconnect failures; stop reading and let + // spawnCli finalizer terminate the child to avoid hanging the invocation. + semanticCompletionController.abort(); + break; + } if (isCliError(event)) { // Codex CLI 0.98+ returns exit code 1 after successful completion. // Suppress the error ONLY if we saw substantive output (item.completed). diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 88b03d3318..8c75d41282 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -464,14 +464,29 @@ async function main(): Promise { // Fail-closed: refuse to start without Redis unless explicitly opted into memory mode. // Also verify Redis is actually reachable (PING), not just configured. + // Retry with backoff: Redis may still be starting after a process-tree restart. if (redis) { - try { - await redis.ping(); - app.log.info('[api] Redis PING OK'); - } catch (err) { + const REDIS_PING_MAX_RETRIES = 5; + const REDIS_PING_BACKOFF_MS = 800; + let lastPingErr: unknown; + for (let attempt = 1; attempt <= REDIS_PING_MAX_RETRIES; attempt++) { + try { + await redis.ping(); + app.log.info(`[api] Redis PING OK (attempt ${attempt})`); + lastPingErr = undefined; + break; + } catch (err) { + lastPingErr = err; + if (attempt < REDIS_PING_MAX_RETRIES) { + app.log.warn(`[api] Redis PING attempt ${attempt}/${REDIS_PING_MAX_RETRIES} failed, retrying in ${REDIS_PING_BACKOFF_MS * attempt}ms...`); + await new Promise((r) => setTimeout(r, REDIS_PING_BACKOFF_MS * attempt)); + } + } + } + if (lastPingErr) { await redis.quit().catch(() => {}); throw new Error( - `[api] Redis PING failed: ${err instanceof Error ? err.message : err}. ` + + `[api] Redis PING failed after ${REDIS_PING_MAX_RETRIES} attempts: ${lastPingErr instanceof Error ? lastPingErr.message : lastPingErr}. ` + 'Check REDIS_URL or set MEMORY_STORE=1 for memory mode.', ); } diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index 1a747d0b52..8c1c4c9c19 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -87,6 +87,7 @@ export interface ConnectorRouterOptions { createdAt: number; lastCommandAt?: number; }; + preferredCats?: readonly CatId[] | undefined; } | null | Promise<{ @@ -98,6 +99,7 @@ export interface ConnectorRouterOptions { createdAt: number; lastCommandAt?: number; }; + preferredCats?: readonly CatId[] | undefined; } | null>; updateProjectPath?(threadId: string, projectPath: string): void | Promise; getParticipantsWithActivity?( @@ -167,6 +169,20 @@ export class ConnectorRouter { return patterns; } + /** + * For connector inbound routing, prefer thread-scoped preferredCats as fallback + * when text has no explicit @mention. This keeps per-group default cat effective. + */ + private async resolveThreadFallbackCat(threadId: string): Promise { + if (!this.opts.threadStore.get) return this.opts.defaultCatId; + const thread = await this.opts.threadStore.get(threadId); + const preferred = Array.isArray(thread?.preferredCats) ? thread.preferredCats : []; + for (const catId of preferred) { + if (typeof catId === 'string' && catId.length > 0) return catId as CatId; + } + return this.opts.defaultCatId; + } + async route( connectorId: string, externalChatId: string, diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index 2a7221ae8c..109ae20e5d 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -344,6 +344,10 @@ export class ConnectorInvokeTrigger { }); } + // F070: Track governance gate errors for outbound error delivery + let governanceErrorReason: string | undefined; + let governanceErrorCatId: string | undefined; + // F151: Deliver per-cat turns inside the loop to preserve ordering when // post_message callbacks from later cats interleave with earlier outboundTurns. const deliveredTurnIndices = new Set(); @@ -451,6 +455,20 @@ export class ConnectorInvokeTrigger { } } } + // F070: Detect governance gate errors for outbound error delivery + if (msg.type === 'system_info' && typeof msg.content === 'string') { + try { + const parsed = JSON.parse(msg.content); + if (parsed.type === 'governance_blocked') { + governanceErrorReason = parsed.reason || 'Governance gate blocked this invocation.'; + governanceErrorCatId = msg.catId; + } + } catch { /* non-JSON system_info — ignore */ } + } + if (msg.type === 'done' && msg.errorCode && !governanceErrorReason) { + governanceErrorReason = `Invocation failed: ${msg.errorCode}`; + governanceErrorCatId = governanceErrorCatId || msg.catId; + } // Collect text content for outbound delivery (final-only) if (msg.type === 'text' && typeof msg.content === 'string') { collectedTextParts.push(msg.content); @@ -650,11 +668,36 @@ export class ConnectorInvokeTrigger { } }); } - } else if (this.opts.streamingHook?.cleanupPlaceholders) { - // Cloud-P1-R3: silent invocation (no content) — still clean up placeholder - await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed (silent)'); - }); + } else { + // Cloud-P1-R3: silent invocation (no content) — clean up placeholder + notify user + if (this.opts.outboundHook) { + // F070: Governance-specific error message when gate blocked the invocation + const fallbackMessage = governanceErrorReason + ? `⚠️ 治理检查未通过,无法派遣猫猫:${governanceErrorReason}` + : '⚠️ 未能生成回复,请重新发送消息或稍后再试。'; + log.warn( + { threadId, governanceErrorReason, governanceErrorCatId }, + '[ConnectorInvokeTrigger] No content produced — delivering fallback to connector', + ); + try { + await this.opts.outboundHook.deliver( + threadId, + fallbackMessage, + (governanceErrorCatId as CatId) ?? catId, + undefined, + undefined, + undefined, + messageId, + ); + } catch (deliverErr) { + log.warn({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Silent invocation fallback delivery failed'); + } + } + if (this.opts.streamingHook?.cleanupPlaceholders) { + await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { + log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed (silent)'); + }); + } } } @@ -687,6 +730,30 @@ export class ConnectorInvokeTrigger { }, threadId, ); + + // Deliver error message to external platforms (Feishu etc.) so user isn't left waiting + if (this.opts.outboundHook) { + try { + await this.opts.outboundHook.deliver( + threadId, + '⚠️ 抱歉,处理消息时出了点问题,请稍后再试。', + catId, + undefined, + undefined, + undefined, + messageId, + ); + } catch (deliverErr) { + log.warn({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Error delivery to connector failed'); + } + } + // Clean up streaming placeholder so it doesn't stay as "收到" forever. + // catch-block scope: createResult is try-local; use the function-scoped invocationId. + if (this.opts.streamingHook?.cleanupPlaceholders && invocationId) { + await this.opts.streamingHook.cleanupPlaceholders(threadId, invocationId).catch((cleanupErr) => { + log.warn({ err: cleanupErr, threadId }, '[ConnectorInvokeTrigger] Placeholder cleanup on error failed'); + }); + } } finally { if (heartbeatInterval) clearInterval(heartbeatInterval); invocationTracker.complete(threadId, catId, controller); 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 52ffc21154..8807340113 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 @@ -44,8 +44,20 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP let branchExistedBefore = false; try { - // 1. Fetch latest origin/main to ensure isolated worktree is current - await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); + // 1. Fetch latest main to ensure isolated worktree is current. + // Fork workflow: if 'upstream' remote exists, fetch from it and use + // upstream/main as source base. This prevents fork-main divergence from + // polluting the PR with unrelated file diffs (the PR targets upstream). + let actualSourceBase = opts.sourceBase; + try { + await exec('git', ['-C', deps.repoRoot, 'remote', 'get-url', 'upstream'], { timeout: 10_000 }); + // upstream remote exists — use it for a clean PR base + await exec('git', ['-C', deps.repoRoot, 'fetch', 'upstream', 'main'], { timeout: 60_000 }); + actualSourceBase = 'upstream/main'; + } catch { + // No upstream remote — single-repo workflow, fetch from origin + await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); + } // Probe upfront so partial-failure cleanup never deletes a pre-existing branch. try { @@ -57,11 +69,11 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP branchExistedBefore = false; } - // 2. Create isolated worktree on a new branch from origin/main + // 2. Create isolated worktree on a new branch from the resolved base // Atomic: fails if branch already exists (race protection) await exec( 'git', - ['-C', deps.repoRoot, 'worktree', 'add', '-b', opts.branchName, worktreePath, opts.sourceBase], + ['-C', deps.repoRoot, 'worktree', 'add', '-b', opts.branchName, worktreePath, actualSourceBase], { timeout: 60_000 }, ); @@ -94,6 +106,33 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const shaResult = await exec('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], { timeout: 10_000 }); const commitSha = shaResult.stdout.trim(); + // 6b. Detect fork workflow: if origin owner differs from the repo gh + // resolves to, we need cross-fork PR syntax (--head owner:branch). + // Without this, gh pr create fails with "Head sha can't be blank" because + // the branch exists on the fork but not on the upstream repo. + let headRef = opts.branchName; + try { + const originUrl = await exec('git', ['-C', worktreePath, 'remote', 'get-url', 'origin'], { + timeout: 10_000, + }); + // Extract owner from origin URL (https://github.com/OWNER/REPO or git@github.com:OWNER/REPO) + const originMatch = originUrl.stdout.trim().match(/github\.com[/:]([^/]+)\//); + if (originMatch) { + const ghRepoResult = await exec('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { + cwd: worktreePath, + timeout: 15_000, + }); + const upstreamOwner = ghRepoResult.stdout.trim().split('/')[0]; + const originOwner = originMatch[1]; + if (originOwner && upstreamOwner && originOwner !== upstreamOwner) { + // Fork workflow: prepend fork owner so gh creates cross-fork PR + headRef = `${originOwner}:${opts.branchName}`; + } + } + } catch { + // Detection failed — fall back to simple branch name (works for non-fork repos) + } + // 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 @@ -118,6 +157,10 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP description: 'F192 keep_observe + no actionable findings — interim per-run PR (rollup deferred)', }, }; + // Track successfully created labels — only pass these to gh pr create. + // If label creation fails (permissions/network/fork mismatch), omitting + // the --label flag is better than letting gh pr create fail entirely. + const successfulLabels: string[] = []; for (const label of labels ?? []) { const meta = standardLabelMeta[label]; const args = ['label', 'create', label, '--force']; @@ -126,13 +169,15 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP } try { await exec('gh', args, { cwd: worktreePath, timeout: 15_000 }); + successfulLabels.push(label); } catch (err) { - // Best-effort: surface error on gh pr create below if it actually breaks PR. - // (Swallowing here = avoid double-fail on label step; PR create will retry.) + // Label creation failed (e.g. insufficient permissions on upstream repo). + // Skip this label to avoid blocking gh pr create with a non-existent + // label reference (gh exits non-zero if --label references missing label). void err; } } - const labelFlags = (labels ?? []).flatMap((label) => ['--label', label]); + const labelFlags = successfulLabels.flatMap((label) => ['--label', label]); const prResult = await exec( 'gh', [ @@ -141,7 +186,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP '--base', 'main', '--head', - opts.branchName, + headRef, '--title', prTitle, '--body', diff --git a/packages/api/test/claude-agent-service.test.js b/packages/api/test/claude-agent-service.test.js index 97aa32db90..872484d2b7 100644 --- a/packages/api/test/claude-agent-service.test.js +++ b/packages/api/test/claude-agent-service.test.js @@ -73,6 +73,16 @@ function createMockSpawnFn(proc) { return mock.fn(() => proc); } +async function waitForSpawn(spawnFn) { + const deadline = Date.now() + 5_000; + while (spawnFn.mock.calls.length === 0) { + if (Date.now() > deadline) { + throw new Error('spawn was not called before timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + function emitProcessExit(proc, code, signal = null) { process.nextTick(() => { proc._emitter.emit('exit', code, signal); @@ -556,6 +566,7 @@ test('preserves inherited Anthropic credentials when no profile mode override is }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -591,6 +602,7 @@ test('F062: subscription profile clears inherited ANTHROPIC env vars', async () }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -628,6 +640,7 @@ test('F062: api_key profile injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL', a }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1065,6 +1078,7 @@ test('falls back to default MCP path when CAT_CAFE_MCP_SERVER_PATH is empty', as }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1084,6 +1098,93 @@ test('falls back to default MCP path when CAT_CAFE_MCP_SERVER_PATH is empty', as } }); +test('builds a strict filtered MCP config from capabilities when callbackEnv is present', async () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-cap-')); + const apiCwd = join(root, 'packages', 'api'); + const catCafeDir = join(root, '.cat-cafe'); + mkdirSync(apiCwd, { recursive: true }); + mkdirSync(catCafeDir, { recursive: true }); + // pnpm-workspace.yaml at root so findMonorepoRoot(apiCwd) resolves to root, + // which is where capabilities.json lives under .cat-cafe/. + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8'); + + // Capabilities with one managed cat-cafe server and one deprecated-external server + const capabilities = { + version: 1, + capabilities: [ + { + id: 'cat-cafe-collab', + type: 'mcp', + enabled: true, + source: 'cat-cafe', + mcpServer: { command: 'node', args: ['placeholder.js'] }, + }, + { + id: 'probe-off', + type: 'mcp', + enabled: true, + source: 'external', + mcpServer: { command: 'node', args: ['probe.js'] }, + }, + ], + }; + writeFileSync(join(catCafeDir, 'capabilities.json'), JSON.stringify(capabilities), 'utf8'); + + const previousCwd = process.cwd(); + const previousRuntimeRoot = process.env.CAT_CAFE_RUNTIME_ROOT; + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + + try { + process.chdir(apiCwd); + // Point resolveBinaryRoot() to the temp monorepo root so canonical paths resolve correctly + process.env.CAT_CAFE_RUNTIME_ROOT = root; + + const service = createClaudeAgentService({ catId: 'opus-47', model: 'claude-test-model', spawnFn }); + const promise = collect( + service.invoke('hello', { + callbackEnv: { + CAT_CAFE_API_URL: 'http://localhost:3004', + CAT_CAFE_INVOCATION_ID: 'inv-cap', + CAT_CAFE_CALLBACK_TOKEN: 'token-cap', + }, + }), + ); + await waitForSpawn(spawnFn); + emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + + // strict mode must be engaged when capabilities resolves servers + assert.ok(args.includes('--strict-mcp-config'), '--strict-mcp-config must be present'); + + // --mcp-config must carry inline JSON (non-Windows path) + const mcpConfigIdx = args.indexOf('--mcp-config'); + assert.ok(mcpConfigIdx >= 0, '--mcp-config must be present'); + const config = JSON.parse(args[mcpConfigIdx + 1]); + + // managed cat-cafe server must appear with canonical dist path + assert.ok(config.mcpServers['cat-cafe-collab'], 'cat-cafe-collab must be included'); + assert.equal(config.mcpServers['cat-cafe-collab'].command, 'node'); + assert.ok( + config.mcpServers['cat-cafe-collab'].args[0].includes(join('packages', 'mcp-server', 'dist', 'collab.js')), + 'cat-cafe-collab args must use canonical collab.js path', + ); + + // deprecated external server must be skipped + assert.ok(!config.mcpServers['probe-off'], 'probe-off must be excluded (deprecated external)'); + } finally { + process.chdir(previousCwd); + if (previousRuntimeRoot === undefined) { + delete process.env.CAT_CAFE_RUNTIME_ROOT; + } else { + process.env.CAT_CAFE_RUNTIME_ROOT = previousRuntimeRoot; + } + rmSync(root, { recursive: true, force: true }); + } +}); + test('F8: result/success extracts usage into done metadata', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); @@ -1384,6 +1485,7 @@ test('third-party model (glm-5): omits --model flag and injects ANTHROPIC_MODEL }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1413,6 +1515,7 @@ test('native Anthropic model (claude-sonnet-4-6): keeps --model flag, no ANTHROP }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index aa059cdb34..c8fcf6727d 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -5,8 +5,9 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, mock, test } from 'node:test'; import { fakeL0Compiler } from './helpers/fake-l0-compiler.js'; @@ -873,6 +874,41 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { assert.ok(errMsg.error.includes('Reconnecting... 2/5'), 'error should include multiple reconnect attempts'); }); + test('terminates immediately when reconnect reaches 5/5 even if child keeps running', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new CodexAgentService({ spawnFn }); + + const promise = collect(service.invoke('reconnect terminal failure')); + + // Intentionally DO NOT close stdout or emit exit: this reproduces a stuck codex child. + proc.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: 'thread-reconnect-stuck' })}\n`); + proc.stdout.write( + `${JSON.stringify({ + type: 'error', + message: 'Reconnecting... 2/5 (stream disconnected before completion)', + })}\n`, + ); + proc.stdout.write( + `${JSON.stringify({ + type: 'error', + message: 'Reconnecting... 5/5 (stream disconnected before completion: Connection refused (os error 61))', + })}\n`, + ); + + const msgs = await Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('invoke did not terminate in time')), 1500)), + ]); + + const errMsg = msgs.find((m) => m.type === 'error'); + assert.ok(errMsg, 'should yield terminal reconnect error'); + assert.ok(errMsg.error.includes('重连 5 次后仍失败')); + assert.ok(errMsg.error.includes('Reconnecting... 5/5')); + assert.ok(msgs.some((m) => m.type === 'done'), 'should always end with done'); + assert.ok(proc.kill.mock.callCount() >= 1, 'should terminate hanging child process'); + }); + test('suppresses exit code 1 when Codex produced substantive output (item.completed)', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); @@ -1026,6 +1062,34 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { assert.equal(spawnOpts.cwd, '/my/project'); }); + test('aliases non-ASCII workingDirectory before spawning Codex CLI', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new CodexAgentService({ spawnFn, model: 'gpt-5.3-codex' }); + const nonAsciiDir = mkdtempSync(join(tmpdir(), 'codex-MSTR策略-')); + let aliasCwd; + + try { + const promise = collect(service.invoke('hi', { workingDirectory: nonAsciiDir })); + emitCodexEvents(proc, [{ type: 'thread.started', thread_id: 't1' }]); + await promise; + + const spawnOpts = spawnFn.mock.calls[0].arguments[2]; + aliasCwd = spawnOpts.cwd; + assert.notEqual(aliasCwd, nonAsciiDir); + assert.ok(!/[^\x00-\x7F]/.test(aliasCwd), `cwd must be ASCII-only, got ${aliasCwd}`); + assert.match(aliasCwd, /clowder-codex-workspaces/); + + const target = readlinkSync(aliasCwd); + assert.equal(resolve(dirname(aliasCwd), target), resolve(nonAsciiDir)); + } finally { + if (aliasCwd?.includes('clowder-codex-workspaces')) { + rmSync(aliasCwd, { force: true }); + } + rmSync(nonAsciiDir, { recursive: true, force: true }); + } + }); + test('oauth mode (default) does not forward OPENAI_API_KEY to codex child env', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); diff --git a/packages/api/test/connector-invoke-error-delivery.test.js b/packages/api/test/connector-invoke-error-delivery.test.js new file mode 100644 index 0000000000..eb2ed4d883 --- /dev/null +++ b/packages/api/test/connector-invoke-error-delivery.test.js @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import './helpers/setup-cat-registry.js'; +import { ConnectorInvokeTrigger } from '../dist/infrastructure/email/ConnectorInvokeTrigger.js'; + +function noopLog() { + const noop = () => {}; + return { + info: noop, + warn: noop, + error: noop, + debug: noop, + trace: noop, + fatal: noop, + child: () => noopLog(), + }; +} + +function mockInvocationRecordStore() { + return { + async create(opts) { + return { outcome: 'created', invocationId: 'inv-test-001' }; + }, + async update() {}, + }; +} + +function mockInvocationTracker() { + return { + has: () => false, + start: () => ({ signal: { aborted: false } }), + complete: () => {}, + }; +} + +function mockInvocationQueue() { + return { + hasQueuedUserMessagesForThread: () => false, + hasActiveOrQueuedAgentForCat: () => false, + }; +} + +function mockSocketManager() { + const messages = []; + return { + messages, + broadcastToRoom() {}, + broadcastAgentMessage(msg) { messages.push(msg); }, + }; +} + +describe('ConnectorInvokeTrigger error delivery', () => { + let outboundDelivered; + let placeholdersCleaned; + let trigger; + let socketManager; + + beforeEach(() => { + outboundDelivered = []; + placeholdersCleaned = []; + socketManager = mockSocketManager(); + }); + + function createTrigger(routerBehavior) { + const router = { + async *routeExecution() { + if (routerBehavior === 'throw') throw new Error('CLI session unavailable'); + if (routerBehavior === 'empty') return; + }, + async ackCollectedCursors() {}, + }; + + return new ConnectorInvokeTrigger({ + router, + socketManager, + invocationRecordStore: mockInvocationRecordStore(), + invocationTracker: mockInvocationTracker(), + invocationQueue: mockInvocationQueue(), + outboundHook: { + async deliver(threadId, content, catId) { + outboundDelivered.push({ threadId, content, catId }); + }, + }, + streamingHook: { + async onStreamStart() {}, + async onStreamChunk() {}, + async onStreamEnd() {}, + async cleanupPlaceholders(threadId, invocationId) { + placeholdersCleaned.push({ threadId, invocationId }); + }, + async notifyDeliveryBatchDone() {}, + }, + log: noopLog(), + }); + } + + it('delivers error message to connector when invocation throws', async () => { + trigger = createTrigger('throw'); + trigger.trigger('thread-1', 'opus', 'user-1', 'hello', 'msg-1'); + // Wait for background execution + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(outboundDelivered.length, 1); + assert.match(outboundDelivered[0].content, /抱歉/); + assert.equal(outboundDelivered[0].threadId, 'thread-1'); + }); + + it('cleans up placeholder when invocation throws', async () => { + trigger = createTrigger('throw'); + trigger.trigger('thread-1', 'opus', 'user-1', 'hello', 'msg-1'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(placeholdersCleaned.length, 1); + assert.equal(placeholdersCleaned[0].threadId, 'thread-1'); + }); + + it('delivers fallback message when invocation produces no content', async () => { + trigger = createTrigger('empty'); + trigger.trigger('thread-2', 'opus', 'user-1', 'hello', 'msg-2'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(outboundDelivered.length, 1); + assert.match(outboundDelivered[0].content, /未能生成回复/); + assert.equal(outboundDelivered[0].threadId, 'thread-2'); + }); + + it('cleans up placeholder when invocation produces no content', async () => { + trigger = createTrigger('empty'); + trigger.trigger('thread-2', 'opus', 'user-1', 'hello', 'msg-2'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(placeholdersCleaned.length, 1); + assert.equal(placeholdersCleaned[0].threadId, 'thread-2'); + }); +}); diff --git a/packages/api/test/connector-router.test.js b/packages/api/test/connector-router.test.js index 354e2e301d..c2ba902e86 100644 --- a/packages/api/test/connector-router.test.js +++ b/packages/api/test/connector-router.test.js @@ -529,6 +529,16 @@ describe('ConnectorRouter', () => { const fwdTrigger = mockTrigger(); const fwdSocket = mockSocketManager(); const fwdStore = mockMessageStore(); + threadStore.threads.set('thread-target-1', { + id: 'thread-target-1', + createdBy: 'owner-1', + title: 'target-thread', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + preferredCats: ['sonnet'], + }); const fwdRouter = new ConnectorRouter({ bindingStore, dedup: new InboundMessageDedup(), diff --git a/packages/api/test/governance/skill-sync.test.js b/packages/api/test/governance/skill-sync.test.js index 9028f03a90..69252b93de 100644 --- a/packages/api/test/governance/skill-sync.test.js +++ b/packages/api/test/governance/skill-sync.test.js @@ -154,6 +154,19 @@ describe('Skill Sync Service (ADR-025 Phase 2)', () => { assert.equal(await readSkillsState(projectRoot), null, 'failed sync should not update skills-state.json'); }); + test('keeps relative symlinks that resolve to the correct target', async () => { + const kimiSkills = join(projectRoot, '.kimi', 'skills'); + await mkdir(kimiSkills, { recursive: true }); + const linkPath = join(kimiSkills, 'tdd'); + const relativeTarget = relative(kimiSkills, join(skillsSource, 'tdd')); + await symlink(relativeTarget, linkPath); + + await syncSkills(projectRoot, skillsSource); + + const target = await readlink(linkPath); + assert.equal(target, relativeTarget, 'correct relative symlink should not be replaced'); + }); + test('is idempotent — second sync produces same result', async () => { const result1 = await syncSkills(projectRoot, skillsSource); const result2 = await syncSkills(projectRoot, skillsSource); diff --git a/packages/api/test/mcp-config-adapters.test.js b/packages/api/test/mcp-config-adapters.test.js index f65b967c53..b071adfb60 100644 --- a/packages/api/test/mcp-config-adapters.test.js +++ b/packages/api/test/mcp-config-adapters.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -19,6 +20,7 @@ import { writeGeminiMcpConfig, writeKimiMcpConfig, } from '../dist/config/capabilities/mcp-config-adapters.js'; +import { findMonorepoRoot } from '../dist/utils/monorepo-root.js'; /** @param {string} prefix */ async function makeTmpDir(prefix) { @@ -396,6 +398,42 @@ describe('writeClaudeMcpConfig', () => { assert.ok(raw.includes('mcpServers')); }); + it('resolveWorkspaceRoot falls back to monorepo root when no workspace env is set', async () => { + // Regression guard for mcp-config-adapters change: + // `return process.cwd()` → `return findMonorepoRoot(process.cwd())` + // When cwd is a subdirectory within a monorepo (pnpm-workspace.yaml at root), + // ALLOWED_WORKSPACE_DIRS must resolve to the monorepo root, not the subdirectory. + const root = mkdtempSync(join(tmpdir(), 'mcp-monorepo-root-')); + const subdir = join(root, 'packages', 'api'); + mkdirSync(subdir, { recursive: true }); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8'); + + const previousCwd = process.cwd(); + const originalAwd = process.env.ALLOWED_WORKSPACE_DIRS; + const originalWs = process.env.CAT_CAFE_WORKSPACE_ROOT; + const file = join(subdir, '.mcp.json'); + try { + process.chdir(subdir); + delete process.env.ALLOWED_WORKSPACE_DIRS; + delete process.env.CAT_CAFE_WORKSPACE_ROOT; + + await writeClaudeMcpConfig(file, [ + { name: 'cat-cafe-collab', command: 'node', args: ['collab.js'], enabled: true, source: 'cat-cafe' }, + ]); + + const data = JSON.parse(await readFile(file, 'utf-8')); + const awd = data.mcpServers['cat-cafe-collab']?.env?.ALLOWED_WORKSPACE_DIRS; + assert.equal(awd, realpathSync(root), `ALLOWED_WORKSPACE_DIRS must be monorepo root (${realpathSync(root)}), got ${awd}`); + } finally { + process.chdir(previousCwd); + if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; + else process.env.ALLOWED_WORKSPACE_DIRS = originalAwd; + if (originalWs === undefined) delete process.env.CAT_CAFE_WORKSPACE_ROOT; + else process.env.CAT_CAFE_WORKSPACE_ROOT = originalWs; + rmSync(root, { recursive: true, force: true }); + } + }); + // F213 Phase B: L5 cleanup applied to Claude writer (.mcp.json). // Same semantics as Codex Phase A: echoLegacyShim removed, fork-like / // third-party preserved, no-op when no legacy. @@ -915,7 +953,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(raw.mcpServers['cat-cafe'].env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), }); } finally { if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; @@ -988,7 +1026,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(legacy.env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), }); } finally { if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; @@ -1030,7 +1068,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(raw.mcpServers['cat-cafe'].env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), EXTRA_FLAG: 'keep-me', }); } finally { diff --git a/packages/web/src/utils/__tests__/api-client-resolve.test.ts b/packages/web/src/utils/__tests__/api-client-resolve.test.ts index 46b708a99c..8f77dc6729 100644 --- a/packages/web/src/utils/__tests__/api-client-resolve.test.ts +++ b/packages/web/src/utils/__tests__/api-client-resolve.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; function stubLocation(overrides: Partial | null) { if (overrides === null) { + // Simulate SSR: no location on globalThis vi.stubGlobal('location', undefined); return; } @@ -11,6 +12,7 @@ function stubLocation(overrides: Partial | null) { hostname: overrides.hostname ?? 'localhost', port: overrides.port ?? '', protocol: overrides.protocol ?? 'http:', + // Enough to satisfy getBrowserLocation() checks ...overrides, }); } @@ -34,6 +36,7 @@ describe('resolveApiUrl', () => { }); async function loadResolveApiUrl() { + // Reset module cache so resolveApiUrl re-evaluates with current mocks vi.resetModules(); const mod = await import('../api-client'); return mod.resolveApiUrl; diff --git a/scripts/clowder-launchd.sh b/scripts/clowder-launchd.sh new file mode 100755 index 0000000000..aa49a7fc92 --- /dev/null +++ b/scripts/clowder-launchd.sh @@ -0,0 +1,347 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +ACTION="${1:-help}" +if [ $# -gt 0 ]; then + shift +fi + +LABEL="${CLOWDER_LAUNCHD_LABEL:-com.cat-cafe.clowder-ai}" +MODE="direct" +USE_MEMORY=false +PREFER_QUICK=true +PLIST_DIR="${HOME}/Library/LaunchAgents" +STATE_DIR="${HOME}/.cat-cafe/launchd" +LOG_DIR="${STATE_DIR}/logs" + +usage() { + cat <<'EOF' +Clowder AI macOS launchd helper + +Usage: + ./scripts/clowder-launchd.sh print-plist [--runtime] [--memory] [--no-quick] [--label LABEL] + ./scripts/clowder-launchd.sh install [--runtime] [--memory] [--no-quick] [--label LABEL] + ./scripts/clowder-launchd.sh uninstall [--label LABEL] + ./scripts/clowder-launchd.sh status [--label LABEL] + ./scripts/clowder-launchd.sh restart [--label LABEL] + +Defaults: + mode: direct (pnpm start:direct) + quick: enabled (reuse existing build artifacts when they exist) + label: com.cat-cafe.clowder-ai + +Notes: + - LaunchAgent runs when the current macOS user logs in. + - direct mode avoids auto-syncing origin/main on every boot. +EOF +} + +die() { + echo "[clowder-launchd] ERROR: $*" >&2 + exit 1 +} + +require_macos() { + [ "$(uname -s)" = "Darwin" ] || die "launchd autostart is only supported on macOS" +} + +xml_escape() { + local value="$1" + value="${value//&/&}" + value="${value///>}" + value="${value//\"/"}" + value="${value//\'/'}" + printf '%s' "$value" +} + +resolve_pnpm_bin() { + if [ -n "${PNPM_BIN:-}" ]; then + printf '%s\n' "$PNPM_BIN" + return 0 + fi + + local resolved + resolved="$(command -v pnpm || true)" + [ -n "$resolved" ] || die "pnpm not found in PATH; set PNPM_BIN explicitly if needed" + printf '%s\n' "$resolved" +} + +launchctl_domain() { + printf 'gui/%s\n' "$(id -u)" +} + +plist_path() { + printf '%s/%s.plist\n' "$PLIST_DIR" "$LABEL" +} + +stdout_log_path() { + printf '%s/%s.log\n' "$LOG_DIR" "$LABEL" +} + +stderr_log_path() { + printf '%s/%s.error.log\n' "$LOG_DIR" "$LABEL" +} + +join_shell_words() { + local out="" + local item + for item in "$@"; do + local quoted + printf -v quoted '%q' "$item" + if [ -z "$out" ]; then + out="$quoted" + else + out="$out $quoted" + fi + done + printf '%s\n' "$out" +} + +build_direct_launch_command() { + local pnpm_bin="$1" + local quick_cmd base_cmd + + if [ "$USE_MEMORY" = true ]; then + quick_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --quick --memory)" + base_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --memory)" + else + quick_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --quick)" + base_cmd="$(join_shell_words "$pnpm_bin" start:direct)" + fi + + if [ "$PREFER_QUICK" = true ]; then + cat < + + + + Label + $(xml_escape "$LABEL") + WorkingDirectory + $(xml_escape "$PROJECT_DIR") + ProgramArguments + + /bin/bash + -lc + $(xml_escape "$launch_cmd") + + EnvironmentVariables + + HOME + $(xml_escape "$HOME") + PATH + $(xml_escape "$path_value") + LANG + $(xml_escape "$lang_value") + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + $(xml_escape "$(stdout_log_path)") + StandardErrorPath + $(xml_escape "$(stderr_log_path)") + + +EOF +} + +install_service() { + require_macos + + local target_plist tmp_plist domain + target_plist="$(plist_path)" + domain="$(launchctl_domain)" + + mkdir -p "$PLIST_DIR" "$LOG_DIR" + tmp_plist="$(mktemp "${STATE_DIR%/}/plist.XXXXXX")" + mkdir -p "$STATE_DIR" + print_plist >"$tmp_plist" + mv "$tmp_plist" "$target_plist" + + launchctl bootout "$domain" "$target_plist" >/dev/null 2>&1 || true + launchctl bootstrap "$domain" "$target_plist" + launchctl enable "$domain/$LABEL" >/dev/null 2>&1 || true + launchctl kickstart -k "$domain/$LABEL" + + cat </dev/null 2>&1 || true + rm -f "$target_plist" + + cat </dev/null 2>&1; then + echo "State: loaded" + launchctl print "$domain/$LABEL" + else + echo "State: not loaded" + exit 1 + fi +} + +restart_service() { + require_macos + + local domain target_plist + domain="$(launchctl_domain)" + target_plist="$(plist_path)" + [ -f "$target_plist" ] || die "plist not found: $target_plist" + + if launchctl print "$domain/$LABEL" >/dev/null 2>&1; then + launchctl kickstart -k "$domain/$LABEL" + else + launchctl bootstrap "$domain" "$target_plist" + launchctl enable "$domain/$LABEL" >/dev/null 2>&1 || true + launchctl kickstart -k "$domain/$LABEL" + fi + + echo "[clowder-launchd] Restarted $LABEL" +} + +while [ $# -gt 0 ]; do + case "$1" in + --runtime) + MODE="runtime" + ;; + --memory) + USE_MEMORY=true + ;; + --no-quick) + PREFER_QUICK=false + ;; + --label) + shift + [ $# -gt 0 ] || die "--label requires a value" + LABEL="$1" + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac + shift +done + +case "$ACTION" in + print-plist) + print_plist + ;; + install) + install_service + ;; + uninstall) + uninstall_service + ;; + status) + status_service + ;; + restart) + restart_service + ;; + help|-h|--help) + usage + ;; + *) + usage + die "unknown action: $ACTION" + ;; +esac diff --git a/scripts/clowder-launchd.test.mjs b/scripts/clowder-launchd.test.mjs new file mode 100644 index 0000000000..e3abf7ddec --- /dev/null +++ b/scripts/clowder-launchd.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testDir, '..'); +const launchdScript = resolve(repoRoot, 'scripts', 'clowder-launchd.sh'); + +describe('clowder-launchd script', () => { + it('prints a direct-mode plist with quick-start fallback and expected log paths', () => { + const fakeHome = mkdtempSync(resolve(tmpdir(), 'clowder-launchd-home-')); + const result = spawnSync('bash', [launchdScript, 'print-plist'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: fakeHome, + PATH: '/opt/homebrew/bin:/usr/bin:/bin', + PNPM_BIN: '/opt/homebrew/bin/pnpm', + CLOWDER_LAUNCHD_LABEL: 'com.cat-cafe.test', + }, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /com\.cat-cafe\.test<\/string>/); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start:direct -- --quick/); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start:direct/); + assert.match(result.stdout, /packages\/web\/\.next\/BUILD_ID/); + assert.match(result.stdout, new RegExp(`${repoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}<\\/string>`)); + assert.match( + result.stdout, + new RegExp(`${resolve(fakeHome, '.cat-cafe', 'launchd', 'logs', 'com.cat-cafe.test.log').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}<\\/string>`), + ); + }); + + it('prints a runtime-mode plist with memory flag when requested', () => { + const fakeHome = mkdtempSync(resolve(tmpdir(), 'clowder-launchd-home-')); + const result = spawnSync('bash', [launchdScript, 'print-plist', '--runtime', '--memory', '--no-quick'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: fakeHome, + PATH: '/opt/homebrew/bin:/usr/bin:/bin', + PNPM_BIN: '/opt/homebrew/bin/pnpm', + }, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start --memory/); + assert.doesNotMatch(result.stdout, /--quick/); + }); +}); diff --git a/scripts/services/tts-api.py b/scripts/services/tts-api.py index 015afca9b6..a147a5f7e6 100644 --- a/scripts/services/tts-api.py +++ b/scripts/services/tts-api.py @@ -346,6 +346,10 @@ class Qwen3CloneAdapter(TtsAdapter): """ DEFAULT_MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" + # Auto-resolve voice ID → ref_audio for clone-based TTS. + # Set TTS_REF_AUDIO_DIR env var to the directory containing {voice_id}.wav files. + _REF_AUDIO_DIR = os.environ.get("TTS_REF_AUDIO_DIR", str(Path.home() / ".tts-ref-audio")) + _REF_TEXT = "大家好,欢迎来到智能语音合成平台,这是一段参考音频。" def __init__(self, model: str | None = None): self._model = model or self.DEFAULT_MODEL @@ -385,6 +389,15 @@ async def synthesize( ref_text = None instruct = None + # Auto-resolve: if no explicit ref_audio but we have a ref file for this voice ID, + # use it for clone mode so different voice IDs produce different timbres. + if not ref_audio and voice: + auto_ref = Path(self._REF_AUDIO_DIR) / f"{voice}.wav" + if auto_ref.exists(): + ref_audio = str(auto_ref) + ref_text = ref_text or self._REF_TEXT + log.info("Auto-resolved ref_audio for voice '%s': %s", voice, ref_audio) + output_dir = Path(tempfile.mkdtemp(prefix="cat-cafe-tts-clone-")) try: kwargs: dict = {