diff --git a/.dir-exceptions.json b/.dir-exceptions.json index 2ac95d3aeb..19f8d5d615 100644 --- a/.dir-exceptions.json +++ b/.dir-exceptions.json @@ -3,71 +3,57 @@ { "path": "packages/api/src/domains/cats/services/agents/routing", "owner": "opus-48", - "reason": "Routing 25 files(agent routing + artifact-tracking + F232 thread-artifacts-aggregator)。与 invocation(25)/providers(32) 同属 agents 子域债务,ADR-010 该子拆分但 scope 不含 F232。F232 加 aggregator 顶过 25 线,time-bound 豁免待 routing 子拆分(记 F23 Phase 2)。", - "expiresAt": "2026-06-30", + "reason": "Routing 25 files(agent routing + artifact-tracking + F232 thread-artifacts-aggregator)。与 invocation(27)/providers(40) 同属 agents 子域债务,ADR-010 该子拆分但 scope 不含 F232。THIRD-ROUND unblock 由同 PR 完成 utils 真拆支撑;time-bound 豁免待 routing 子拆分(记 F23 Phase 2)。", + "expiresAt": "2026-07-31", "ticket": "F23-followup" }, { "path": "packages/api/src/routes", "owner": "opus", - "reason": "Routes 按功能已分文件(147 files),ADR-010 重构 scope 不含 routes,需要按 HTTP verb / domain 重新评估子拆分边界。SECOND-ROUND unblock(首轮 a4e81b8791 续期到 2026-06-01)。Follow-up split plan 记在 F23 § Phase 2 follow-up。", - "expiresAt": "2026-06-30", + "reason": "Routes 按功能已分文件(171 files),ADR-010 重构 scope 不含 routes,需要按 HTTP verb / domain 重新评估子拆分边界。THIRD-ROUND unblock 由同 PR 完成 utils 真拆支撑。Follow-up split plan 记在 F23 § Phase 2 follow-up。", + "expiresAt": "2026-07-31", "ticket": "F23-followup" }, { "path": "packages/api/src/config", "owner": "opus", - "reason": "Config 38 files(cat-budgets/cat-config/env 等独立配置项)。需要先做 config 域 audit 再确定子拆分边界。SECOND-ROUND unblock(首轮 a4e81b8791 续期到 2026-06-01)。Follow-up split plan 记在 F23 § Phase 2 follow-up。", - "expiresAt": "2026-06-30", + "reason": "Config 39 files(cat-budgets/cat-config/env 等独立配置项)。需要先做 config 域 audit 再确定子拆分边界。THIRD-ROUND unblock 由同 PR 完成 utils 真拆支撑。Follow-up split plan 记在 F23 § Phase 2 follow-up。", + "expiresAt": "2026-07-31", "ticket": "F23-followup" }, { "path": "packages/api/src/domains/cats/services/agents/invocation", "owner": "opus", - "reason": "Invocation 控制面 25 files;按 queue/registry/tracker/progress/reconciliation/delivery/auth 子域拆分。SECOND-ROUND unblock(首轮 4136847b10 砚砚 2026-05-18 续期到 2026-06-01)。Follow-up split plan 记在 F23 § Phase 2 follow-up。", - "expiresAt": "2026-06-30", + "reason": "Invocation 控制面 27 files;按 queue/registry/tracker/progress/reconciliation/delivery/auth 子域拆分。THIRD-ROUND unblock 由同 PR 完成 utils 真拆支撑。Follow-up split plan 记在 F23 § Phase 2 follow-up。", + "expiresAt": "2026-07-31", "ticket": "F23-followup" }, { "path": "packages/api/src/domains/cats/services/agents/providers", "owner": "opus", - "reason": "Agent providers 32 files;按 agents/event-transforms/carriers/image/configs 子域拆分。SECOND-ROUND unblock(首轮 4136847b10 砚砚 2026-05-18 续期到 2026-06-01)。Follow-up split plan 记在 F23 § Phase 2 follow-up。", - "expiresAt": "2026-06-30", + "reason": "Agent providers 40 files;按 agents/event-transforms/carriers/image/configs 子域拆分。THIRD-ROUND unblock 由同 PR 完成 utils 真拆支撑。Follow-up split plan 记在 F23 § Phase 2 follow-up。", + "expiresAt": "2026-07-31", "ticket": "F23-followup" }, { "path": "packages/api/src/domains/cats/services/stores/ports", "owner": "codex", - "reason": "F231 Phase C 新增 ProfileUpdateProposalStore port 后,stores/ports 达到 25 files;当前 store port 集中目录仍是既有模式,Phase C scope 不做跨 store-port 拆分。需在 F23/F231 follow-up 中按 proposal/profile/session 等子域拆分 ports。", - "expiresAt": "2026-06-30", + "reason": "F231 Phase C 新增 ProfileUpdateProposalStore port 后,stores/ports 达到 26 files;当前 store port 集中目录仍是既有模式,Phase C scope 不做跨 store-port 拆分。需在 F23/F231 follow-up 中按 proposal/profile/session 等子域拆分 ports。", + "expiresAt": "2026-07-31", "ticket": "F231/F23-followup" }, { "path": "packages/api/src/domains/memory", "owner": "gpt52", - "reason": "Memory 域现有 72 个非 index.ts 文件,职责已横跨 indexing/query/governance/library;SECOND-ROUND unblock(首轮续期到 2026-06-15),继续按真实边界拆子目录。", - "expiresAt": "2026-06-30", + "reason": "Memory 域现有 89 个非 index.ts 文件,职责已横跨 indexing/query/governance/library;继续按真实边界拆子目录。", + "expiresAt": "2026-07-31", "ticket": "F102" }, - { - "path": "packages/api/src/utils", - "owner": "codex", - "reason": "API utils 31 files;按 cli/process/media/paths/network/parsing/skills 子域拆分(详见 F23 § Phase 2)。SECOND-ROUND unblock(首轮 4136847b10 砚砚 2026-05-18 续期到 2026-06-01)。Follow-up split plan 记在 F23 § Phase 2 follow-up。", - "expiresAt": "2026-06-30", - "ticket": "F23-followup" - }, - { - "path": "packages/api/src/infrastructure/harness-eval", - "owner": "opus", - "reason": "F192 socio-technical harness eval 当前 29 .ts(Phase F capability-wakeup 一批 eval-capability-wakeup-* 加入超 error=25);SECOND-ROUND unblock(首轮续期到 2026-06-15),后续按 capability-wakeup / a2a / domain / hub 子域拆分。详见 F192 feat doc Known Debt + GitHub issue。", - "expiresAt": "2026-06-30", - "ticket": "F192" - }, { "path": "packages/api/src/domains/cats/services/stores/redis", "owner": "opus", "reason": "F235 RedisCommunityIssueDraftStore 加入后 redis/ 达 25 files;与 ports/(26) 同源——每个 store port 一个 Redis impl。需在 F23 follow-up 中按 proposal/community/session 子域拆分。", - "expiresAt": "2026-06-30", + "expiresAt": "2026-07-31", "ticket": "F23-followup" } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8734fc6786..d91b1886e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: - 'designs/**' - 'assets/**' pull_request: - branches: [main] + branches: [main, develop] paths-ignore: - 'docs/**' - '*.md' diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index 928ee6419e..34a543ea34 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -9,7 +9,7 @@ name: Windows Smoke on: pull_request: - branches: [main] + branches: [main, develop] paths-ignore: - 'docs/**' - '*.md' diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7d57bf7409..e1890f5865 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,6 +66,7 @@ created: 2026-02-26 | F203 | Native System Prompt L0 — 压缩免疫核心规则注入 | in-progress | Ragdoll Opus 4.7 | internal | [F203](features/F203-native-system-prompt-l0.md) | | F204 | Weixin MP Publisher Plugin — 微信公众号文章发布插件 | review | community @mindfn + maintainers | community [#688](https://github.com/zts212653/clowder-ai/pull/688) | [F204](features/F204-weixin-mp-publisher-plugin.md) | | F205 | MediaHub Video Provider Plugins — 视频生成/分析插件 | spec | community @mindfn + maintainers | community [#689](https://github.com/zts212653/clowder-ai/pull/689) | [F205](features/F205-video-provider-plugins.md) | +| F207 | AI Family Office — 个人投资学习基建 | spec | Ragdoll | internal | [F207](features/F207-personal-finance-infra.md) | | F208 | Capability Profile Routing — 能力画像档案 + 认知路由 | spec | Ragdoll | internal | [F208](features/F208-capability-profile-routing.md) | | F193 | Cross-Thread Communication Unification (Phase E: 发现即投递) | in-progress | Ragdoll (Opus 4.6) | internal | [F193](features/F193-cross-thread-comm-unification.md) | | F210 | Gemini CLI to Antigravity CLI Migration | in-progress | Maine Coon/Maine Coon | internal | [F210](features/F210-antigravity-cli-migration.md) | diff --git a/docs/features/F023-directory-corrosion-defense.md b/docs/features/F023-directory-corrosion-defense.md index 321b814e99..ddf218ee72 100644 --- a/docs/features/F023-directory-corrosion-defense.md +++ b/docs/features/F023-directory-corrosion-defense.md @@ -40,6 +40,8 @@ created: 2026-02-26 这条 gate 落到 `docs/SOP.md` 「outbound sync 基线修复」段(this PR 不顺手改 SOP,由 Phase 2 第一个真拆 PR 一起落)。 +**2026-07-07 PR #40 CI unblock**:满足第三轮 unblock 条件 (b)。同 PR 已将 `packages/api/src/utils` 按 `cli/process/media/paths/network/parsing/skills` 真拆,删除对应 `.dir-exceptions.json` 条目;`packages/api/src/infrastructure/harness-eval` 已低于阈值,也删除过期例外。其余仍超阈值目录仅续期到 2026-07-31。 + ### 5 目录 concrete split map | 目录 | 文件数 | Owner | 子目录拆分方案 | Target | diff --git a/packages/api/config/public-test-exclusions.json b/packages/api/config/public-test-exclusions.json index dc307748e5..37824a9879 100644 --- a/packages/api/config/public-test-exclusions.json +++ b/packages/api/config/public-test-exclusions.json @@ -8,7 +8,7 @@ "reason": "Redis isolation and persistence tests are internal harness coverage, not part of the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "concurrent-fault-drill", @@ -17,7 +17,7 @@ "reason": "Fault-drill stress coverage is too heavy for the public gate and stays in internal validation.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "task-progress-store", @@ -26,7 +26,7 @@ "reason": "Task progress store behavior depends on internal persistence surfaces not exported to the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "session-strategy-phase3", @@ -35,7 +35,7 @@ "reason": "Phase 3 session strategy assertions cover internal rollout behavior outside the public gate contract.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "signal-article-store", @@ -44,7 +44,7 @@ "reason": "Signal article store cases exercise source-only data plumbing not guaranteed in the public export.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "persistence-fault-drill", @@ -53,7 +53,7 @@ "reason": "Persistence fault drills are heavyweight stress scenarios reserved for internal validation.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "cursor-store-atomicity", @@ -62,7 +62,7 @@ "reason": "Atomic cursor store checks cover internal durability mechanics not enforced by the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "workflow-sop-store", @@ -71,7 +71,7 @@ "reason": "Workflow SOP store tests rely on internal governance persistence surfaces excluded from the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "dare-agent-service", @@ -80,7 +80,7 @@ "reason": "DARE agent service coverage is internal-only agent runtime behavior, not public gate surface.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "dare-l1-acceptance", @@ -89,7 +89,7 @@ "reason": "DARE L1 acceptance exercises internal harness scenarios not exported publicly.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "codex-agent-service", @@ -98,7 +98,7 @@ "reason": "Codex agent runtime service tests depend on internal carrier/harness wiring outside the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "kimi-agent-service", @@ -107,7 +107,7 @@ "reason": "Kimi agent service coverage is internal runtime behavior, not public gate contract.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "claude-settings-hooks", @@ -116,7 +116,7 @@ "reason": "Claude settings hook assertions depend on private runtime fixtures unavailable in public export.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "game-store", @@ -125,7 +125,7 @@ "reason": "Game store tests rely on non-exported local fixtures and are kept out of the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "memory-tests", @@ -134,7 +134,7 @@ "reason": "Memory package tests cover internal recall/index behavior outside the public gate promise.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "cross-cat-context", @@ -143,7 +143,7 @@ "reason": "Cross-cat context routing is internal collaboration harness behavior, not public gate surface.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "thread-wiring", @@ -152,7 +152,7 @@ "reason": "Thread wiring assertions cover internal callback plumbing outside the public gate contract.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "integration-wiring", @@ -161,7 +161,7 @@ "reason": "Integration wiring checks depend on internal connection topology not exported to public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "shared-state-wiring", @@ -170,7 +170,7 @@ "reason": "Shared-state wiring is internal runtime glue not part of the public gate guarantee.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "signal-fetcher-launchd", @@ -179,7 +179,7 @@ "reason": "launchd-specific signal fetcher coverage depends on private macOS fixtures and stays internal.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "reflection-capsule-m3", @@ -188,7 +188,7 @@ "reason": "Reflection capsule M3 tests cover internal memory/harness behavior outside the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "workspace-project-context", @@ -197,7 +197,7 @@ "reason": "Workspace project context assertions depend on source-only repo structure and local project wiring.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "projects-setup", @@ -206,7 +206,7 @@ "reason": "Project setup tests cover internal bootstrap flows not guaranteed in the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "projects-mkdir", @@ -215,7 +215,7 @@ "reason": "Project directory creation coverage depends on source-only filesystem conventions.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "governance-status", @@ -224,7 +224,7 @@ "reason": "Governance status assertions are source-owned workflow coverage, not public gate behavior.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "governance-pack", @@ -233,7 +233,7 @@ "reason": "Governance pack assertions validate source-only sync content and are intentionally excluded from public gate.", "owner": "@zts212653", "introducedBy": "069d0f0fb", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "pack-integration", @@ -242,7 +242,7 @@ "reason": "Pack integration coverage exercises internal source packaging rules outside the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "project-setup-flow", @@ -251,7 +251,7 @@ "reason": "Project setup flow behavior is internal bootstrap coverage, not public gate contract.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "process-liveness-probe", @@ -260,7 +260,7 @@ "reason": "Process liveness probe timing is too contention-sensitive for the public gate.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "expedition-bootstrap", @@ -269,7 +269,7 @@ "reason": "Expedition bootstrap coverage depends on source-owned harness flows not exported publicly.", "owner": "@zts212653", "introducedBy": "78f3bc57c", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "rules-route", @@ -278,7 +278,7 @@ "reason": "Rules route assertions depend on source-only rule artifacts stripped from public export.", "owner": "@zts212653", "introducedBy": "4243948da", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "root-md-slim", @@ -287,7 +287,7 @@ "reason": "Root markdown slim tests rely on source-specific Chinese anchors not preserved in public export.", "owner": "@zts212653", "introducedBy": "7a300704a", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "audit-cc-system-prompt", @@ -296,7 +296,7 @@ "reason": "System prompt audit depends on internal L0 prompt assets not shipped in public export.", "owner": "@zts212653", "introducedBy": "e9bb56052", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "f188-cold-start-fixtures", @@ -305,7 +305,7 @@ "reason": "F188 cold-start fixture coverage depends on internal fixture files absent from public export.", "owner": "@zts212653", "introducedBy": "e9bb56052", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "f188-harness-consistency", @@ -314,7 +314,7 @@ "reason": "F188 harness consistency checks rely on internal fixtures and remain source-only.", "owner": "@zts212653", "introducedBy": "e9bb56052", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "orphan-chrome-cleaner", @@ -323,7 +323,7 @@ "reason": "Orphan Chrome cleaner assertions are sensitive to local path sanitization and private fixtures.", "owner": "@zts212653", "introducedBy": "e9bb56052", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "capabilities-route", @@ -332,7 +332,7 @@ "reason": "Managed MCP path realignment currently fails in public gate and must be tracked as a real product regression.", "owner": "@zts212653", "introducedBy": "e9bb56052", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "antigravity-run-command-executor", @@ -341,7 +341,7 @@ "reason": "Antigravity run-command executor timing is unstable on CI and remains out of the public gate until hardened.", "owner": "@zts212653", "introducedBy": "0340783c6", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "f203-phase-i-opencode-l0", @@ -350,7 +350,7 @@ "reason": "F203 opencode L0 coverage depends on internal runtime files not exported publicly.", "owner": "@zts212653", "introducedBy": "9c4f26fde", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "github-schedule-factories", @@ -359,7 +359,7 @@ "reason": "GitHub schedule factory assertions are source-owned public-sync governance coverage, not runtime gate behavior.", "owner": "@zts212653", "introducedBy": "bd8823b99", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "harness-eval-hub-read-model", @@ -368,7 +368,7 @@ "reason": "Eval hub read-model coverage exercises source-owned harness governance surfaces outside the public gate.", "owner": "@zts212653", "introducedBy": "bd8823b99", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" }, { "id": "harness-eval-merge-gate-provenance-contract", @@ -377,7 +377,7 @@ "reason": "Merge-gate provenance contract tests are source-owned governance coverage, not public runtime behavior.", "owner": "@zts212653", "introducedBy": "bd8823b99", - "expiresOn": "2026-06-30" + "expiresOn": "2026-07-31" } ] } diff --git a/packages/api/src/config/ConfigRegistry.ts b/packages/api/src/config/ConfigRegistry.ts index 37a728dd5d..359dad4880 100644 --- a/packages/api/src/config/ConfigRegistry.ts +++ b/packages/api/src/config/ConfigRegistry.ts @@ -7,7 +7,7 @@ */ import { catRegistry } from '@cat-cafe/shared'; -import { DEFAULT_CLI_TIMEOUT_MS, readCliTimeoutMsFromEnv } from '../utils/cli-timeout.js'; +import { DEFAULT_CLI_TIMEOUT_MS, readCliTimeoutMsFromEnv } from '../utils/cli/cli-timeout.js'; import { configStore } from './ConfigStore.js'; import { getAllCatBudgets } from './cat-budgets.js'; import { getCoCreatorConfig } from './cat-config-loader.js'; diff --git a/packages/api/src/config/capabilities/capability-orchestrator.ts b/packages/api/src/config/capabilities/capability-orchestrator.ts index bb2d7f201f..df1b93bd68 100644 --- a/packages/api/src/config/capabilities/capability-orchestrator.ts +++ b/packages/api/src/config/capabilities/capability-orchestrator.ts @@ -16,7 +16,7 @@ import { homedir } from 'node:os'; import { delimiter, dirname, extname, join, relative, resolve, sep } from 'node:path'; import type { CapabilitiesConfig, CapabilityEntry, McpServerDescriptor } from '@cat-cafe/shared'; import { catRegistry } from '@cat-cafe/shared'; -import { resolveCatCafeSkillsSource } from '../../utils/skill-source.js'; +import { resolveCatCafeSkillsSource } from '../../utils/skills/skill-source.js'; import { migrateCapabilitiesV1ToV2 } from '../governance/capabilities-migration.js'; import { cleanStaleClaudeProjectOverrides, diff --git a/packages/api/src/config/capabilities/capability-write-guards.ts b/packages/api/src/config/capabilities/capability-write-guards.ts index 48d060b08b..0c699ad94c 100644 --- a/packages/api/src/config/capabilities/capability-write-guards.ts +++ b/packages/api/src/config/capabilities/capability-write-guards.ts @@ -1,5 +1,5 @@ import type { FastifyRequest } from 'fastify'; -import { isLoopbackAddress } from '../../utils/loopback-request.js'; +import { isLoopbackAddress } from '../../utils/network/loopback-request.js'; import { resolveOwnerGate } from '../../utils/owner-gate.js'; import { REDACTED_CAPABILITY_SECRET } from './capability-redaction.js'; diff --git a/packages/api/src/config/capabilities/startup-cli-config.ts b/packages/api/src/config/capabilities/startup-cli-config.ts index 57e92eecbf..75c09f240b 100644 --- a/packages/api/src/config/capabilities/startup-cli-config.ts +++ b/packages/api/src/config/capabilities/startup-cli-config.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { findMonorepoRoot } from '../../utils/monorepo-root.js'; +import { findMonorepoRoot } from '../../utils/paths/monorepo-root.js'; import { type CliConfigPaths, generateCliConfigs, diff --git a/packages/api/src/config/cat-voices.ts b/packages/api/src/config/cat-voices.ts index f9f5e3ac2c..dab4fb7959 100644 --- a/packages/api/src/config/cat-voices.ts +++ b/packages/api/src/config/cat-voices.ts @@ -19,7 +19,7 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { VoiceConfig } from '@cat-cafe/shared'; import { catRegistry } from '@cat-cafe/shared'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; import { resolveBreedId } from './breed-resolver.js'; import { getAllCatIdsFromConfig, loadCatConfig } from './cat-config-loader.js'; diff --git a/packages/api/src/config/connector-secret-updater.ts b/packages/api/src/config/connector-secret-updater.ts index c4b8fb1df0..be072d84b0 100644 --- a/packages/api/src/config/connector-secret-updater.ts +++ b/packages/api/src/config/connector-secret-updater.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { applyEnvUpdatesToFile } from '../routes/config.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { configEventBus, createChangeSetId } from './config-event-bus.js'; export interface ConnectorSecretUpdate { diff --git a/packages/api/src/config/connector-secret-write-guards.ts b/packages/api/src/config/connector-secret-write-guards.ts index ac924d2b7b..a857e08895 100644 --- a/packages/api/src/config/connector-secret-write-guards.ts +++ b/packages/api/src/config/connector-secret-write-guards.ts @@ -1,6 +1,6 @@ import type { FastifyRequest } from 'fastify'; import { normalizeTelegramBotToken } from '../infrastructure/connectors/telegram-token.js'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; import { isConnectorSecret } from './connector-secrets-allowlist.js'; diff --git a/packages/api/src/config/env-registry.ts b/packages/api/src/config/env-registry.ts index 1c27278280..1023e4d54f 100644 --- a/packages/api/src/config/env-registry.ts +++ b/packages/api/src/config/env-registry.ts @@ -12,7 +12,7 @@ * The "环境 & 文件" tab picks it up automatically. */ -import { DEFAULT_CLI_TIMEOUT_LABEL } from '../utils/cli-timeout.js'; +import { DEFAULT_CLI_TIMEOUT_LABEL } from '../utils/cli/cli-timeout.js'; export type EnvCategory = | 'server' diff --git a/packages/api/src/config/governance/governance-bootstrap.ts b/packages/api/src/config/governance/governance-bootstrap.ts index 3a70606b48..e858b52051 100644 --- a/packages/api/src/config/governance/governance-bootstrap.ts +++ b/packages/api/src/config/governance/governance-bootstrap.ts @@ -16,8 +16,8 @@ import { STANDARD_MOUNT_POINT_IDS, } from '@cat-cafe/shared'; import { updateSkillMountPaths, writeSkillsSyncState } from '../../skills/skill-sync-config.js'; -import { pathsEqual } from '../../utils/project-path.js'; -import { computeSourceManifestHash } from '../../utils/skill-source.js'; +import { pathsEqual } from '../../utils/paths/project-path.js'; +import { computeSourceManifestHash } from '../../utils/skills/skill-source.js'; import { readCapabilitiesConfig, writeCapabilitiesConfig } from '../capabilities/capability-orchestrator.js'; import { readMountRules } from '../mount/mount-rules-store.js'; import type { Provider } from './governance-pack.js'; diff --git a/packages/api/src/config/governance/governance-preflight.ts b/packages/api/src/config/governance/governance-preflight.ts index 7b231baea0..e90ba63d33 100644 --- a/packages/api/src/config/governance/governance-preflight.ts +++ b/packages/api/src/config/governance/governance-preflight.ts @@ -8,7 +8,7 @@ */ import { lstat, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { isSameProject } from '../../utils/monorepo-root.js'; +import { isSameProject } from '../../utils/paths/monorepo-root.js'; import type { Provider } from './governance-pack.js'; import { MANAGED_BLOCK_START } from './governance-pack.js'; import { GovernanceRegistry } from './governance-registry.js'; @@ -23,8 +23,11 @@ export interface PreflightResult { const CAT_PROVIDER_MAP: Record = { anthropic: 'claude', + claude: 'claude', openai: 'codex', + codex: 'codex', google: 'gemini', + gemini: 'gemini', kimi: 'kimi', }; diff --git a/packages/api/src/config/governance/governance-registry.ts b/packages/api/src/config/governance/governance-registry.ts index 49a581528d..650e5a53a3 100644 --- a/packages/api/src/config/governance/governance-registry.ts +++ b/packages/api/src/config/governance/governance-registry.ts @@ -9,7 +9,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { relative, resolve, sep } from 'node:path'; import type { GovernanceHealthSummary, GovernancePackMeta } from '@cat-cafe/shared'; -import { pathsEqual } from '../../utils/project-path.js'; +import { pathsEqual } from '../../utils/paths/project-path.js'; import { GOVERNANCE_PACK_VERSION } from './governance-pack.js'; const REGISTRY_DIR = '.cat-cafe'; diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts index e1151f34b8..9e7f7c22c2 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationTracker.ts @@ -10,7 +10,7 @@ */ import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { resolveCliTimeoutMs } from '../../../../../utils/cli-timeout.js'; +import { resolveCliTimeoutMs } from '../../../../../utils/cli/cli-timeout.js'; const log = createModuleLogger('invocation-tracker'); diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index 634ce7dc4e..a9dcfb5f6f 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -7,7 +7,7 @@ * - processNext(用户级):co-creator手动触发处理自己的下一条 */ -import { resolveCliTimeoutMs } from '../../../../../utils/cli-timeout.js'; +import { resolveCliTimeoutMs } from '../../../../../utils/cli/cli-timeout.js'; import { emitQueueUpdated, enrichQueueEntries } from '../../../../../utils/queue-enrichment.js'; import { hydrateReplyPreview, type IMessageStore } from '../../stores/ports/MessageStore.js'; import { mergeTokenUsage, type TokenUsage } from '../../types.js'; 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 416f81099e..ec6ad5a114 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 @@ -69,12 +69,12 @@ import { recordToolUseSpan, } from '../../../../../infrastructure/telemetry/span-helpers.js'; import { ToolSpanTracker } from '../../../../../infrastructure/telemetry/tool-span-tracker.js'; -import { resolveActiveProjectRoot } from '../../../../../utils/active-project-root.js'; -import { resolveCliCommand } from '../../../../../utils/cli-resolve.js'; -import { DEFAULT_CLI_TIMEOUT_MS, resolveCliTimeoutMs } from '../../../../../utils/cli-timeout.js'; -import { findMonorepoRoot, isSameProject } from '../../../../../utils/monorepo-root.js'; -import { isUnderAllowedRoot } from '../../../../../utils/project-path.js'; -import { tcpProbe } from '../../../../../utils/tcp-probe.js'; +import { resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; +import { DEFAULT_CLI_TIMEOUT_MS, resolveCliTimeoutMs } from '../../../../../utils/cli/cli-timeout.js'; +import { tcpProbe } from '../../../../../utils/network/tcp-probe.js'; +import { resolveActiveProjectRoot } from '../../../../../utils/paths/active-project-root.js'; +import { findMonorepoRoot, isSameProject } from '../../../../../utils/paths/monorepo-root.js'; +import { isUnderAllowedRoot } from '../../../../../utils/paths/project-path.js'; import type { AgentPaneRegistry } from '../../../../terminal/agent-pane-registry.js'; import type { TmuxGateway } from '../../../../terminal/tmux-gateway.js'; import { resolveBootcampWorkspaceRoot } from '../../bootcamp/workspace-root.js'; @@ -1244,7 +1244,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP await tryGovernanceBootstrap(workingDirectory, catCafeRoot); const { checkGovernancePreflight } = await import('../../../../../config/governance/governance-preflight.js'); const catEntry = catRegistry.tryGet(catId as string); - const preflight = await checkGovernancePreflight(workingDirectory, catCafeRoot, catEntry?.config.clientId); + const clientId = catEntry?.config.clientId; + const preflight = await checkGovernancePreflight(workingDirectory, catCafeRoot, clientId); if (!preflight.ready) { const reasonKind = preflight.needsBootstrap ? 'needs_bootstrap' @@ -1261,6 +1262,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP reasonKind, reason: preflight.reason, invocationId: params.parentInvocationId, + ...(clientId ? { clientId } : {}), }), timestamp: Date.now(), }; 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 2d735df3de..ee58b08fc9 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts @@ -22,11 +22,11 @@ import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatEffort } from '../../../../../config/cat-config-loader.js'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli-diagnostics.js'; -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 { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli/cli-diagnostics.js'; +import { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.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'; diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts index 1f4f2c0784..da32b549e2 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeBgCarrierService.ts @@ -31,8 +31,8 @@ import { isAbsolute, join, resolve } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { resolveCliCommandOrBare } from '../../../../../utils/cli-resolve.js'; -import { buildChildEnv } from '../../../../../utils/cli-spawn.js'; +import { resolveCliCommandOrBare } from '../../../../../utils/cli/cli-resolve.js'; +import { buildChildEnv } from '../../../../../utils/cli/cli-spawn.js'; import type { AgentMessage, AgentService, AgentServiceOptions } from '../../types.js'; import { accumulateUsageFromEntries, 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 50e2cf7f18..52d9425be8 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -24,11 +24,11 @@ import { getCatModel } from '../../../../../config/cat-models.js'; import { getCodexApprovalPolicy, getCodexSandboxMode } from '../../../../../config/codex-cli.js'; import { estimateCostFromTokens } from '../../../../../config/model-pricing.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -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 { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; +import { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.js'; +import { sanitizeCliStderr } from '../../../../../utils/cli/sanitize-cli-stderr.js'; import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAuditLog.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata, TokenUsage } from '../../types.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/DareAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/DareAgentService.ts index 7cff6af5d3..2e129dd87c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/DareAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/DareAgentService.ts @@ -20,9 +20,9 @@ import { join } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { formatCliExitError } from '../../../../../utils/cli-format.js'; -import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; -import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../types.js'; import { transformDareEvent } from './dare-event-transform.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts index 36c3abcdfc..2ed3ef1adf 100644 --- a/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/GeminiAgentService.ts @@ -25,9 +25,9 @@ import { basename, join, resolve } from 'node:path'; import { type AgyProfileConfig, type CatId, type CliDiagnostics, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli-diagnostics.js'; -import { formatCliExitError } from '../../../../../utils/cli-format.js'; -import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; +import { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli/cli-diagnostics.js'; +import { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; import { buildChildEnv, isCliError, @@ -35,11 +35,11 @@ import { isCliTimeout, isLivenessWarning, spawnCli, -} from '../../../../../utils/cli-spawn.js'; -import { resolveCliTimeoutMs } from '../../../../../utils/cli-timeout.js'; -import type { SpawnFn } from '../../../../../utils/cli-types.js'; -import { readJsonlTail } from '../../../../../utils/jsonl-tail-reader.js'; -import { sanitizeCliStderr } from '../../../../../utils/sanitize-cli-stderr.js'; +} from '../../../../../utils/cli/cli-spawn.js'; +import { resolveCliTimeoutMs } from '../../../../../utils/cli/cli-timeout.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.js'; +import { sanitizeCliStderr } from '../../../../../utils/cli/sanitize-cli-stderr.js'; +import { readJsonlTail } from '../../../../../utils/parsing/jsonl-tail-reader.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata, TokenUsage } from '../../types.js'; import { appendLocalImagePathHints, collectImageAccessDirectories } from '../providers/image-cli-bridge.js'; import { extractImagePaths } from '../providers/image-paths.js'; @@ -916,7 +916,7 @@ export class GeminiAgentService implements AgentService { invocationId?: string; rawArchivePath?: string; // F212 Phase A (砚砚 2nd P2): cliDiagnostics piggyback on __cliTimeout - cliDiagnostics?: import('../../../../../utils/cli-diagnostics.js').CliDiagnostics; + cliDiagnostics?: import('../../../../../utils/cli/cli-diagnostics.js').CliDiagnostics; } | undefined; let cliErrorEvent: @@ -928,7 +928,7 @@ export class GeminiAgentService implements AgentService { command: string; reasonCode?: string; // F212 Phase A: structured CLI diagnostics piggybacking on __cliError event - cliDiagnostics?: import('../../../../../utils/cli-diagnostics.js').CliDiagnostics; + cliDiagnostics?: import('../../../../../utils/cli/cli-diagnostics.js').CliDiagnostics; } | undefined; let cancelled = false; diff --git a/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts index c7c70174c1..35bc37ada6 100644 --- a/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/KimiAgentService.ts @@ -5,10 +5,10 @@ import { dirname } from 'node:path'; import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -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 { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.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'; diff --git a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts index c97f524ebc..f2345da33a 100644 --- a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts @@ -17,11 +17,11 @@ import { type CatId, createCatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli-diagnostics.js'; -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 { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli/cli-diagnostics.js'; +import { formatCliExitError } from '../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../utils/cli/cli-types.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentServiceOptions, L0InjectableAgentService, MessageMetadata } from '../../types.js'; import type { RawArchiveSink } from '../providers/codex-audit-hooks.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts index 97b650b588..c0291e97ef 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts @@ -16,8 +16,8 @@ import { dirname, isAbsolute } from 'node:path'; import { createInterface, type Interface as ReadlineInterface } from 'node:readline'; import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; -import { resolveCliCommandOrBare } from '../../../../../../utils/cli-resolve.js'; -import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli-spawn-win.js'; +import { resolveCliCommandOrBare } from '../../../../../../utils/cli/cli-resolve.js'; +import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli/cli-spawn-win.js'; import type { AcpAgentRequest, AcpContentBlock, diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpHttpStreamClient.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpHttpStreamClient.ts index e022594cf9..352d76ebbb 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpHttpStreamClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpHttpStreamClient.ts @@ -23,8 +23,8 @@ import { dirname, isAbsolute } from 'node:path'; import { createInterface } from 'node:readline'; import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; -import { resolveCliCommandOrBare } from '../../../../../../utils/cli-resolve.js'; -import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli-spawn-win.js'; +import { resolveCliCommandOrBare } from '../../../../../../utils/cli/cli-resolve.js'; +import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli/cli-spawn-win.js'; import { type AcpCapacitySignal, type AcpClientConfig, diff --git a/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-image-publisher.ts b/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-image-publisher.ts index 4d040d3560..9ba7ceffbc 100644 --- a/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-image-publisher.ts +++ b/packages/api/src/domains/cats/services/agents/providers/antigravity/antigravity-image-publisher.ts @@ -3,7 +3,7 @@ import { readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { extname, join } from 'node:path'; import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; -import { ALLOWED_IMAGE_MIMES, type SupportedImageMime } from '../../../../../../utils/image-storage.js'; +import { ALLOWED_IMAGE_MIMES, type SupportedImageMime } from '../../../../../../utils/media/image-storage.js'; import { type PublishedGeneratedImage, publishGeneratedImage } from '../generated-image-publication.js'; import type { TrajectoryStep } from './AntigravityBridge.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts index 14ea0ab7ca..3c249b254b 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts @@ -118,7 +118,7 @@ async function* parseOpenAIChatStream( const reader = body.getReader(); const decoder = new TextDecoder('utf-8', { fatal: false }); let buffer = ''; - let dataLines: string[] = []; + const dataLines: string[] = []; const ctx: OpenAIStreamContext = { text: '', textSeen: false, @@ -289,9 +289,7 @@ export class OpenAIChatAdapter implements CatAgentProtocolAdapter { const body: Record = { model: input.model, max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS, - messages: input.systemPrompt - ? [{ role: 'system' as const, content: input.systemPrompt }, ...messages] - : messages, + messages: input.systemPrompt ? [{ role: 'system' as const, content: input.systemPrompt }, ...messages] : messages, stream: true, stream_options: { include_usage: true }, }; diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-agent-win.ts b/packages/api/src/domains/cats/services/agents/providers/claude-agent-win.ts index 8a48f8d5ed..ecd67e2180 100644 --- a/packages/api/src/domains/cats/services/agents/providers/claude-agent-win.ts +++ b/packages/api/src/domains/cats/services/agents/providers/claude-agent-win.ts @@ -2,4 +2,4 @@ * Claude Agent Windows Helpers * Re-exports Git Bash detection from shared utility. */ -export { findGitBashPath, pickGitBashPathFromWhere } from '../../../../../utils/cli-spawn-win.js'; +export { findGitBashPath, pickGitBashPathFromWhere } from '../../../../../utils/cli/cli-spawn-win.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts index 67f21a576c..6fb3f7c81e 100644 --- a/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts @@ -6,10 +6,10 @@ */ import { type CatId, createCatId } from '@cat-cafe/shared'; -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 { formatCliExitError } from '../../../../../../utils/cli/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../../utils/cli/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../../utils/cli/cli-spawn.js'; +import type { SpawnFn } from '../../../../../../utils/cli/cli-types.js'; import { CliRawArchive } from '../../../session/CliRawArchive.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../../types.js'; import { type RawArchiveSink, sanitizeRawEvent } from '../codex-audit-hooks.js'; diff --git a/packages/api/src/domains/cats/services/agents/providers/codex-image-scanner.ts b/packages/api/src/domains/cats/services/agents/providers/codex-image-scanner.ts index 1c6fc1f6ac..1225d62cbf 100644 --- a/packages/api/src/domains/cats/services/agents/providers/codex-image-scanner.ts +++ b/packages/api/src/domains/cats/services/agents/providers/codex-image-scanner.ts @@ -1,7 +1,7 @@ import { readdir } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { ALLOWED_IMAGE_MIMES, type SupportedImageMime } from '../../../../../utils/image-storage.js'; +import { ALLOWED_IMAGE_MIMES, type SupportedImageMime } from '../../../../../utils/media/image-storage.js'; import { type PublishedGeneratedImage, publishGeneratedImage } from './generated-image-publication.js'; const log = createModuleLogger('codex-image-scanner'); diff --git a/packages/api/src/domains/cats/services/agents/providers/generated-image-publication.ts b/packages/api/src/domains/cats/services/agents/providers/generated-image-publication.ts index 9ef829b454..dfe7db6d1a 100644 --- a/packages/api/src/domains/cats/services/agents/providers/generated-image-publication.ts +++ b/packages/api/src/domains/cats/services/agents/providers/generated-image-publication.ts @@ -8,8 +8,8 @@ import { type SavedImageAsset, type SupportedImageMime, sanitizeFilenameStem, -} from '../../../../../utils/image-storage.js'; -import { getDefaultUploadDir } from '../../../../../utils/upload-paths.js'; +} from '../../../../../utils/media/image-storage.js'; +import { getDefaultUploadDir } from '../../../../../utils/media/upload-paths.js'; export interface GeneratedImagePublicationInput { sourcePath: string; diff --git a/packages/api/src/domains/cats/services/agents/providers/image-paths.ts b/packages/api/src/domains/cats/services/agents/providers/image-paths.ts index 2980bd672b..44eb0caad1 100644 --- a/packages/api/src/domains/cats/services/agents/providers/image-paths.ts +++ b/packages/api/src/domains/cats/services/agents/providers/image-paths.ts @@ -5,7 +5,7 @@ import { resolve } from 'node:path'; import type { MessageContent } from '@cat-cafe/shared'; -import { getDefaultUploadDir, resolveInternalRouteUrl } from '../../../../../utils/upload-paths.js'; +import { getDefaultUploadDir, resolveInternalRouteUrl } from '../../../../../utils/media/upload-paths.js'; /** * Extract absolute image file paths from contentBlocks. diff --git a/packages/api/src/domains/cats/services/bootcamp/workspace-root.ts b/packages/api/src/domains/cats/services/bootcamp/workspace-root.ts index 01c12b8963..685f8e5dfd 100644 --- a/packages/api/src/domains/cats/services/bootcamp/workspace-root.ts +++ b/packages/api/src/domains/cats/services/bootcamp/workspace-root.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { validateProjectPath } from '../../../../utils/project-path.js'; +import { validateProjectPath } from '../../../../utils/paths/project-path.js'; export type BootcampWorkspaceRootResolution = { ok: true; projectPath: string } | { ok: false; error: string }; diff --git a/packages/api/src/domains/cats/services/context/governance-l0.ts b/packages/api/src/domains/cats/services/context/governance-l0.ts index 82cde52a12..dbd656a688 100644 --- a/packages/api/src/domains/cats/services/context/governance-l0.ts +++ b/packages/api/src/domains/cats/services/context/governance-l0.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename, dirname, extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { findMonorepoRoot } from '../../../../utils/monorepo-root.js'; +import { findMonorepoRoot } from '../../../../utils/paths/monorepo-root.js'; /** * Derive the install root from this module's file path. diff --git a/packages/api/src/domains/cats/services/first-run-quest/client-detection.ts b/packages/api/src/domains/cats/services/first-run-quest/client-detection.ts index 7b5d4bf33e..94710459ce 100644 --- a/packages/api/src/domains/cats/services/first-run-quest/client-detection.ts +++ b/packages/api/src/domains/cats/services/first-run-quest/client-detection.ts @@ -12,7 +12,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { resolveCliCommand } from '../../../../utils/cli-resolve.js'; +import { resolveCliCommand } from '../../../../utils/cli/cli-resolve.js'; const execFileAsync = promisify(execFile); diff --git a/packages/api/src/domains/cats/services/types.ts b/packages/api/src/domains/cats/services/types.ts index 1934f91261..a24713a10a 100644 --- a/packages/api/src/domains/cats/services/types.ts +++ b/packages/api/src/domains/cats/services/types.ts @@ -5,8 +5,8 @@ import type { CatId, MessageContent, ReplyPreview, TaskStatus } from '@cat-cafe/shared'; import type { Span } from '@opentelemetry/api'; -import type { CliDiagnostics } from '../../../utils/cli-diagnostics.js'; -import type { CliSpawnOptions } from '../../../utils/cli-types.js'; +import type { CliDiagnostics } from '../../../utils/cli/cli-diagnostics.js'; +import type { CliSpawnOptions } from '../../../utils/cli/cli-types.js'; import type { AntigravitySessionLifecycle } from './agents/providers/antigravity/antigravity-runtime-lifecycle.js'; /** F8: Unified token usage type across all three cats. diff --git a/packages/api/src/domains/plugin/PluginResourceActivator.ts b/packages/api/src/domains/plugin/PluginResourceActivator.ts index ea17317c9f..0874dbf760 100644 --- a/packages/api/src/domains/plugin/PluginResourceActivator.ts +++ b/packages/api/src/domains/plugin/PluginResourceActivator.ts @@ -15,7 +15,7 @@ import { readMountRules } from '../../config/mount/mount-rules-store.js'; import type { TaskSpec_P1 } from '../../infrastructure/scheduler/types.js'; import { mountSkillSymlinks, unmountSkillSymlinks } from '../../skills/skill-manage.js'; import { classifyMountPath } from '../../skills/skill-sync-engine.js'; -import { buildSkillMountTargets, isManagedDirectoryLevelSkillsSymlink } from '../../utils/skill-mount.js'; +import { buildSkillMountTargets, isManagedDirectoryLevelSkillsSymlink } from '../../utils/skills/skill-mount.js'; import type { LimbRegistry } from '../limb/LimbRegistry.js'; import { computeAgentProviderDescriptorHash } from './agent-provider-descriptor-hash.js'; import { normalizeCapId, resolvePluginResourcePath, resourceCapId, resourcePathBasename } from './PluginRegistry.js'; @@ -36,6 +36,9 @@ export interface ActivatePluginResult { } export type LimbAdapterFactory = (pluginId: string, limbYamlPath: string) => Promise; +type AgentProviderDescriptorInput = + | AgentProviderCapabilityDescriptor + | ((existing: AgentProviderCapabilityDescriptor | undefined) => AgentProviderCapabilityDescriptor); /** Minimal TaskRunner interface for schedule resource activation (F202 Phase 2) */ export interface ScheduleTaskRunner { @@ -493,9 +496,10 @@ export class PluginResourceActivator { private async activateAgentProvider(manifest: PluginManifest, resource: PluginResourceDef): Promise { if (!resource.agentProvider) throw new Error('AgentProvider resource must have an agentProvider descriptor'); + const resourceAgentProvider = resource.agentProvider; if (!this.deps.providerTransportRegistry) throw new Error('ProviderTransportRegistry not configured'); - if (!this.deps.providerTransportRegistry.has(resource.agentProvider.transport)) { - throw new Error(`Unknown agentProvider transport '${resource.agentProvider.transport}'`); + if (!this.deps.providerTransportRegistry.has(resourceAgentProvider.transport)) { + throw new Error(`Unknown agentProvider transport '${resourceAgentProvider.transport}'`); } // F241 Phase B Slice 2b: compute the canonical descriptor hash so we can decide @@ -507,11 +511,11 @@ export class PluginResourceActivator { const descriptorHash = computeAgentProviderDescriptorHash({ pluginId: manifest.id, capId, - resource: resource.agentProvider, + resource: resourceAgentProvider, }); - const existing = await this.readExistingAgentProviderDescriptor(manifest.id, capId); - - const agentProvider: AgentProviderCapabilityDescriptor = + const buildAgentProvider = ( + existing: AgentProviderCapabilityDescriptor | undefined, + ): AgentProviderCapabilityDescriptor => existing && existing.descriptorHash === descriptorHash ? { // Descriptor unchanged — preserve host-owned state verbatim. Spread `existing` @@ -519,7 +523,7 @@ export class PluginResourceActivator { // any non-hash-contributing manifest field (none today, but future-proofing) // is refreshed without disturbing host state. ...existing, - ...resource.agentProvider, + ...resourceAgentProvider, state: existing.state, routeable: existing.routeable, routeableApproved: existing.routeableApproved, @@ -530,33 +534,14 @@ export class PluginResourceActivator { : { // New activation or descriptor delta — reset host-owned state to fail-closed // defaults. `health` and `lastSyncError` are intentionally omitted (undefined). - ...resource.agentProvider, + ...resourceAgentProvider, state: 'transportReady', routeable: false, routeableApproved: false, descriptorHash, }; - await this.upsertCapabilityEntry(manifest, resource, true, undefined, undefined, agentProvider); - } - - /** - * F241 Phase B Slice 2b: read the existing agentProvider descriptor for a given - * (pluginId, capId), if any. Returns undefined when no capability row exists, when - * the row belongs to a different plugin, or when the row is not an agentProvider. - * Pure read — no mutation, no side effects. - */ - private async readExistingAgentProviderDescriptor( - pluginId: string, - capId: string, - ): Promise { - const config = await this.deps.readCapabilities(); - if (!config) return undefined; - const entry = config.capabilities.find((c) => normalizeCapId(c.id) === capId); - if (!entry || entry.type !== 'agentProvider' || entry.pluginId !== pluginId) { - return undefined; - } - return entry.agentProvider; + await this.upsertCapabilityEntry(manifest, resource, true, undefined, undefined, buildAgentProvider); } private async deactivateAgentProvider(manifest: PluginManifest, resource: PluginResourceDef): Promise { @@ -569,7 +554,7 @@ export class PluginResourceActivator { enabled: boolean, limbNodeId?: string, scheduleTaskId?: string, - agentProvider?: AgentProviderCapabilityDescriptor, + agentProvider?: AgentProviderDescriptorInput, ): Promise { return this.deps.withCapabilityLock(async () => { const config = await this.deps.readCapabilities(); @@ -580,6 +565,15 @@ export class PluginResourceActivator { let staleLimbNodeIdToClean: string | undefined; let staleScheduleTaskIdToClean: string | undefined; const existing = cap.capabilities.find((c) => normalizeCapId(c.id) === capId); + const resolveAgentProvider = ( + entry: CapabilityEntry | undefined, + ): AgentProviderCapabilityDescriptor | undefined => { + if (!agentProvider) return undefined; + if (typeof agentProvider !== 'function') return agentProvider; + const existingDescriptor = + entry?.type === 'agentProvider' && entry.pluginId === manifest.id ? entry.agentProvider : undefined; + return agentProvider(existingDescriptor); + }; if (existing) { if (existing.pluginId !== undefined && existing.pluginId !== manifest.id) { throw new Error(`Capability '${capId}' is already owned by plugin '${existing.pluginId}'`); @@ -622,7 +616,8 @@ export class PluginResourceActivator { delete existing.mcpServer; delete existing.limbNodeId; delete existing.scheduleTaskId; - if (agentProvider) existing.agentProvider = agentProvider; + const nextAgentProvider = resolveAgentProvider(existing); + if (nextAgentProvider) existing.agentProvider = nextAgentProvider; } else { delete existing.mcpServer; delete existing.scheduleTaskId; @@ -636,6 +631,7 @@ export class PluginResourceActivator { // staleLimbNodeId is deregistered after the write below staleLimbNodeIdToClean = staleLimbNodeId; } else { + const nextAgentProvider = resolveAgentProvider(undefined); const entry: CapabilityEntry = { id: capId, type: resource.type as CapabilityEntry['type'], @@ -644,7 +640,7 @@ export class PluginResourceActivator { pluginId: manifest.id, ...(limbNodeId ? { limbNodeId } : {}), ...(scheduleTaskId ? { scheduleTaskId } : {}), - ...(agentProvider ? { agentProvider } : {}), + ...(nextAgentProvider ? { agentProvider: nextAgentProvider } : {}), }; if (resource.type === 'mcp') { diff --git a/packages/api/src/domains/plugin/RoutingAdmissionService.ts b/packages/api/src/domains/plugin/RoutingAdmissionService.ts index d7db0ebb4d..b08fba7731 100644 --- a/packages/api/src/domains/plugin/RoutingAdmissionService.ts +++ b/packages/api/src/domains/plugin/RoutingAdmissionService.ts @@ -98,6 +98,20 @@ export type RoutingAdmissionResult = readonly details: string; }; +/** + * Normalize every routeable identity surface to the same key used by mention + * routing: trim whitespace, strip one leading `@`, then compare + * case-insensitively. Provider ids and cat ids normally have no `@`, so they + * naturally keep their bare lower-case key. + */ +export function normalizeRouteableIdentityClaim(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + const withoutMentionPrefix = trimmed.startsWith('@') ? trimmed.slice(1).trim() : trimmed; + if (withoutMentionPrefix.length === 0) return undefined; + return withoutMentionPrefix.toLowerCase(); +} + /** * Decide whether the candidate may be promoted to `routeable: true`. * @@ -133,8 +147,14 @@ export function admitForRouting( }; } + const normalizedSnapshot = { + templateBaselineIds: normalizeIdentitySet(snapshot.templateBaselineIds), + existingRouteableIdentities: normalizeIdentitySet(snapshot.existingRouteableIdentities), + activeNonProviderTransportIdentities: normalizeIdentitySet(snapshot.activeNonProviderTransportIdentities), + }; + for (const claim of claims) { - if (snapshot.templateBaselineIds.has(claim)) { + if (normalizedSnapshot.templateBaselineIds.has(claim)) { return { admitted: false, reason: 'reserved-baseline-collision', @@ -145,7 +165,7 @@ export function admitForRouting( } for (const claim of claims) { - if (snapshot.existingRouteableIdentities.has(claim)) { + if (normalizedSnapshot.existingRouteableIdentities.has(claim)) { return { admitted: false, reason: 'existing-routeable-collision', @@ -156,7 +176,7 @@ export function admitForRouting( } for (const claim of claims) { - if (snapshot.activeNonProviderTransportIdentities.has(claim)) { + if (normalizedSnapshot.activeNonProviderTransportIdentities.has(claim)) { return { admitted: false, reason: 'active-cat-collision', @@ -178,12 +198,11 @@ function collectIdentityClaims(candidate: RoutingAdmissionCandidate): string[] { const seen = new Set(); const ordered: string[] = []; const push = (value: string | undefined): void => { - if (!value) return; - const trimmed = value.trim(); - if (trimmed.length === 0) return; - if (seen.has(trimmed)) return; - seen.add(trimmed); - ordered.push(trimmed); + const normalized = normalizeRouteableIdentityClaim(value); + if (!normalized) return; + if (seen.has(normalized)) return; + seen.add(normalized); + ordered.push(normalized); }; push(candidate.providerId); push(candidate.catId); @@ -193,3 +212,12 @@ function collectIdentityClaims(candidate: RoutingAdmissionCandidate): string[] { } return ordered; } + +function normalizeIdentitySet(values: ReadonlySet): ReadonlySet { + const normalized = new Set(); + for (const value of values) { + const identity = normalizeRouteableIdentityClaim(value); + if (identity) normalized.add(identity); + } + return normalized; +} diff --git a/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts b/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts index f1259e82d3..82a7a4bc4d 100644 --- a/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts +++ b/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts @@ -17,7 +17,7 @@ import type { AgentProviderCapabilityDescriptor, CapabilitiesConfig, CatConfig } from '@cat-cafe/shared'; import { normalizeCapId } from './PluginRegistry.js'; -import type { RoutingAdmissionSnapshot } from './RoutingAdmissionService.js'; +import { normalizeRouteableIdentityClaim, type RoutingAdmissionSnapshot } from './RoutingAdmissionService.js'; export interface AgentProviderAdmissionSnapshotInputs { /** Capability config snapshot (the source of existing routeable identities). */ @@ -52,6 +52,10 @@ export function buildAgentProviderAdmissionSnapshot( // routeableBinding catId / profileId / mentionPatterns even if their `name` // fields differ. const existingRouteableIdentities = new Set(); + const addIdentity = (set: Set, value: string | undefined): void => { + const normalized = normalizeRouteableIdentityClaim(value); + if (normalized) set.add(normalized); + }; for (const cap of inputs.capabilitiesConfig?.capabilities ?? []) { if (cap.type !== 'agentProvider' || !cap.agentProvider) continue; // Skip the candidate itself — Slice 1 red line: never let the snapshot @@ -61,13 +65,13 @@ export function buildAgentProviderAdmissionSnapshot( } const descriptor = cap.agentProvider as AgentProviderCapabilityDescriptor; if (!descriptor.routeable) continue; - if (descriptor.name) existingRouteableIdentities.add(descriptor.name); + addIdentity(existingRouteableIdentities, descriptor.providerId ?? descriptor.name); const binding = descriptor.routeableBinding; if (binding) { - if (binding.catId) existingRouteableIdentities.add(binding.catId); - if (binding.profileId) existingRouteableIdentities.add(binding.profileId); + addIdentity(existingRouteableIdentities, binding.catId); + addIdentity(existingRouteableIdentities, binding.profileId); for (const pattern of binding.mentionPatterns ?? []) { - if (pattern) existingRouteableIdentities.add(pattern); + addIdentity(existingRouteableIdentities, pattern); } } } @@ -92,14 +96,18 @@ export function buildAgentProviderAdmissionSnapshot( for (const [id, config] of Object.entries(inputs.activeCatConfigs)) { if (inputs.hasProviderTransportConfig(id)) continue; if ((config as { pluginProjection?: unknown }).pluginProjection !== undefined) continue; - activeNonProviderTransportIdentities.add(id); + addIdentity(activeNonProviderTransportIdentities, id); for (const pattern of config.mentionPatterns ?? []) { - if (pattern) activeNonProviderTransportIdentities.add(pattern); + addIdentity(activeNonProviderTransportIdentities, pattern); } } return { - templateBaselineIds: new Set(inputs.templateBaselineIds), + templateBaselineIds: new Set( + [...inputs.templateBaselineIds] + .map((identity) => normalizeRouteableIdentityClaim(identity)) + .filter((identity): identity is string => identity !== undefined), + ), existingRouteableIdentities, activeNonProviderTransportIdentities, }; diff --git a/packages/api/src/domains/plugin/agent-provider-approval-service.ts b/packages/api/src/domains/plugin/agent-provider-approval-service.ts index e10e911e68..4b435032b5 100644 --- a/packages/api/src/domains/plugin/agent-provider-approval-service.ts +++ b/packages/api/src/domains/plugin/agent-provider-approval-service.ts @@ -187,7 +187,7 @@ export class AgentProviderApprovalService { const candidate: RoutingAdmissionCandidate = { pluginId: request.pluginId, capId: request.capId, - providerId: descriptor.name, + providerId: descriptor.providerId ?? descriptor.name, catId: request.catId, profileId: request.profileId, mentionPatterns: request.mentionPatterns, diff --git a/packages/api/src/domains/plugin/agent-provider-health-executor.ts b/packages/api/src/domains/plugin/agent-provider-health-executor.ts index 4f1739c143..cbeb078d1b 100644 --- a/packages/api/src/domains/plugin/agent-provider-health-executor.ts +++ b/packages/api/src/domains/plugin/agent-provider-health-executor.ts @@ -32,7 +32,7 @@ import type { AgentProviderHealthResult, PluginAgentProviderResource, } from '@cat-cafe/shared'; -import { resolveCliCommand } from '../../utils/cli-resolve.js'; +import { resolveCliCommand } from '../../utils/cli/cli-resolve.js'; import type { ProviderTransportRegistry } from '../cats/services/agents/providers/transport/ProviderTransportRegistry.js'; /** Inputs to a single health check run. */ diff --git a/packages/api/src/domains/plugin/agent-provider-projection.ts b/packages/api/src/domains/plugin/agent-provider-projection.ts index aa93ab4ceb..8f6dc8d469 100644 --- a/packages/api/src/domains/plugin/agent-provider-projection.ts +++ b/packages/api/src/domains/plugin/agent-provider-projection.ts @@ -118,7 +118,7 @@ export function projectRouteableAgentProviders(inputs: AgentProviderProjectionIn const candidate: RoutingAdmissionCandidate = { pluginId: row.pluginId, capId: row.capId, - providerId: descriptor.name, + providerId: descriptor.providerId ?? descriptor.name, catId: binding.catId, profileId: binding.profileId, mentionPatterns: binding.mentionPatterns, diff --git a/packages/api/src/domains/terminal/tmux-agent-spawner.ts b/packages/api/src/domains/terminal/tmux-agent-spawner.ts index 2d6c25fb3c..0e221faee8 100644 --- a/packages/api/src/domains/terminal/tmux-agent-spawner.ts +++ b/packages/api/src/domains/terminal/tmux-agent-spawner.ts @@ -16,10 +16,10 @@ import type { Interface as ReadlineInterface } from 'node:readline'; import { createInterface } from 'node:readline'; import { promisify } from 'node:util'; import { createModuleLogger } from '../../infrastructure/logger.js'; -import { buildCliDiagnostics } from '../../utils/cli-diagnostics.js'; -import { maybeCollectStreamError } from '../../utils/cli-spawn.js'; -import { resolveCliTimeoutMs } from '../../utils/cli-timeout.js'; -import type { CliSpawnOptions } from '../../utils/cli-types.js'; +import { buildCliDiagnostics } from '../../utils/cli/cli-diagnostics.js'; +import { maybeCollectStreamError } from '../../utils/cli/cli-spawn.js'; +import { resolveCliTimeoutMs } from '../../utils/cli/cli-timeout.js'; +import type { CliSpawnOptions } from '../../utils/cli/cli-types.js'; // parseNDJSON not used directly — we create readline inline for killability. import type { SpawnCliOverride } from '../cats/services/types.js'; import type { AgentPaneRegistry } from './agent-pane-registry.js'; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d7b03f9b8e..7d5f449b60 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -262,11 +262,11 @@ import { terminalRoutes } from './routes/terminal.js'; import { threadExportRoutes } from './routes/thread-export.js'; import { threadMemberStrategyRoutes } from './routes/thread-member-strategy.js'; import { ApiInstanceLease, type ApiInstanceLeaseInvalidation } from './services/ApiInstanceLease.js'; -import { resolveActiveProjectRoot } from './utils/active-project-root.js'; -import { resolveMemoryRepoPaths } from './utils/memory-root.js'; -import { findMonorepoRoot } from './utils/monorepo-root.js'; +import { getDefaultUploadDir } from './utils/media/upload-paths.js'; +import { resolveActiveProjectRoot } from './utils/paths/active-project-root.js'; +import { resolveMemoryRepoPaths } from './utils/paths/memory-root.js'; +import { findMonorepoRoot } from './utils/paths/monorepo-root.js'; import { resolveUserId } from './utils/request-identity.js'; -import { getDefaultUploadDir } from './utils/upload-paths.js'; const PORT = parseInt(process.env.API_SERVER_PORT ?? '3004', 10); const HOST = process.env.API_SERVER_HOST ?? '127.0.0.1'; @@ -686,7 +686,7 @@ async function main(): Promise { const { resolve } = await import('node:path'); const { repoRoot, docsRoot, markersDir } = resolveMemoryRepoPaths(process.cwd()); - const { initRepoIdentity, isSameRepo } = await import('./utils/is-same-repo.js'); + const { initRepoIdentity, isSameRepo } = await import('./utils/paths/is-same-repo.js'); initRepoIdentity(repoRoot); const { createMemoryServices } = await import('./domains/memory/factory.js'); @@ -3448,7 +3448,7 @@ async function main(): Promise { // F145 P0: Kill orphan agent-browser headless Chrome processes from previous sessions. try { - const { cleanOrphanAgentBrowserChrome } = await import('./utils/orphan-chrome-cleaner.js'); + const { cleanOrphanAgentBrowserChrome } = await import('./utils/process/orphan-chrome-cleaner.js'); await cleanOrphanAgentBrowserChrome(app.log); } catch (err) { app.log.warn(`[api] Orphan Chrome cleanup failed (best-effort): ${String(err)}`); diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index 975afc75de..a1b9105000 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -18,7 +18,7 @@ import type { CatId, ConnectorDefinition, ConnectorSource, MessageContent } from '@cat-cafe/shared'; import { catRegistry, getConnectorDefinition } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; -import { findMonorepoRoot } from '../../utils/monorepo-root.js'; +import { findMonorepoRoot } from '../../utils/paths/monorepo-root.js'; import type { ConnectorCommandLayer } from './ConnectorCommandLayer.js'; import { type CardAction, ConnectorMessageFormatter, DEFAULT_QUICK_ACTIONS } from './ConnectorMessageFormatter.js'; import type { IConnectorPermissionStore } from './ConnectorPermissionStore.js'; diff --git a/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts b/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts index ce9fc23800..075c6b2fa7 100644 --- a/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts +++ b/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type CatId, catRegistry, type RichBlock } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; -import { resolveInternalRouteUrl } from '../../utils/upload-paths.js'; +import { resolveInternalRouteUrl } from '../../utils/media/upload-paths.js'; import { ConnectorMessageFormatter, type MessageEnvelope, type MessageOrigin } from './ConnectorMessageFormatter.js'; import type { IConnectorThreadBindingStore } from './ConnectorThreadBindingStore.js'; import { renderAllRichBlocksPlaintext } from './rich-block-plaintext.js'; diff --git a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts index d8c2d14349..94e7da44bc 100644 --- a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts +++ b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts @@ -28,8 +28,8 @@ import type { RedisClient } from '@cat-cafe/shared/utils'; import type { FastifyBaseLogger } from 'fastify'; import { isCatAvailable } from '../../config/cat-config-loader.js'; import type { ConnectorWebhookHandler } from '../../routes/connector-webhooks.js'; -import { resolveActiveProjectRoot } from '../../utils/active-project-root.js'; -import { getDefaultUploadDir } from '../../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../../utils/media/upload-paths.js'; +import { resolveActiveProjectRoot } from '../../utils/paths/active-project-root.js'; import { encodeDefault } from '../config-field-parser.js'; import { deliverConnectorMessage } from '../email/deliver-connector-message.js'; import { ConnectorCommandLayer, type ConnectorCommandLayerDeps } from './ConnectorCommandLayer.js'; diff --git a/packages/api/src/routes/accounts.ts b/packages/api/src/routes/accounts.ts index 09e885a59d..71056ed74a 100644 --- a/packages/api/src/routes/accounts.ts +++ b/packages/api/src/routes/accounts.ts @@ -16,9 +16,9 @@ import { deleteCatalogAccount, readCatalogAccounts, writeCatalogAccount } from ' import { configEventBus, createChangeSetId } from '../config/config-event-bus.js'; import { deleteCredential, hasCredential, writeCredential } from '../config/credentials.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; -import { findMonorepoRoot } from '../utils/monorepo-root.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; +import { findMonorepoRoot } from '../utils/paths/monorepo-root.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; // clowder-ai#340: Derive client identity from well-known account IDs, not stored protocol. diff --git a/packages/api/src/routes/agent-hooks.ts b/packages/api/src/routes/agent-hooks.ts index dfd9248059..a69dea7b70 100644 --- a/packages/api/src/routes/agent-hooks.ts +++ b/packages/api/src/routes/agent-hooks.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os'; import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import { getAgentHookStatus, syncAgentHooks } from '../agent-hooks/index.js'; -import { findMonorepoRoot } from '../utils/monorepo-root.js'; +import { findMonorepoRoot } from '../utils/paths/monorepo-root.js'; export interface AgentHooksRouteOptions { projectRoot?: string; diff --git a/packages/api/src/routes/avatars.ts b/packages/api/src/routes/avatars.ts index 8869bd50d2..3ff1c7a889 100644 --- a/packages/api/src/routes/avatars.ts +++ b/packages/api/src/routes/avatars.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { AVATAR_RAW_FILE_LIMIT_BYTES } from '@cat-cafe/shared'; import multipart from '@fastify/multipart'; import type { FastifyPluginAsync } from 'fastify'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; const ACCEPTED_IMAGE_MIME = ['image/png', 'image/jpeg', 'image/webp'] as const; type AcceptedMime = (typeof ACCEPTED_IMAGE_MIME)[number]; diff --git a/packages/api/src/routes/callback-auth-debug.ts b/packages/api/src/routes/callback-auth-debug.ts index a851fa8117..9f456b911d 100644 --- a/packages/api/src/routes/callback-auth-debug.ts +++ b/packages/api/src/routes/callback-auth-debug.ts @@ -17,7 +17,7 @@ import { createCatId } from '@cat-cafe/shared'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; import type { CallbackAuthSystemMessageNotifier } from './callback-auth-system-message.js'; import { diff --git a/packages/api/src/routes/callback-document-routes.ts b/packages/api/src/routes/callback-document-routes.ts index 7e5ef0938d..012137c51d 100644 --- a/packages/api/src/routes/callback-document-routes.ts +++ b/packages/api/src/routes/callback-document-routes.ts @@ -16,7 +16,7 @@ import type { InvocationRegistry } from '../domains/cats/services/agents/invocat import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/RichBlockBuffer.js'; import { PandocService } from '../infrastructure/document/PandocService.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; import { requireCallbackAuth } from './callback-auth-prehandler.js'; const generateDocumentSchema = z.object({ diff --git a/packages/api/src/routes/callback-guide-routes.ts b/packages/api/src/routes/callback-guide-routes.ts index 984c9aa808..87202daf2b 100644 --- a/packages/api/src/routes/callback-guide-routes.ts +++ b/packages/api/src/routes/callback-guide-routes.ts @@ -16,7 +16,7 @@ import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadS import { GuideLifecycleService } from '../domains/guides/GuideLifecycleService.js'; import { createGuideStoreBridge, type IGuideSessionStore } from '../domains/guides/GuideSessionRepository.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { requireCallbackAuth } from './callback-auth-prehandler.js'; // --------------------------------------------------------------------------- diff --git a/packages/api/src/routes/callback-propose-thread-routes.ts b/packages/api/src/routes/callback-propose-thread-routes.ts index 97f9dde98b..63edaf21a8 100644 --- a/packages/api/src/routes/callback-propose-thread-routes.ts +++ b/packages/api/src/routes/callback-propose-thread-routes.ts @@ -19,7 +19,7 @@ import type { IProposalStore } from '../domains/cats/services/stores/ports/Propo import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { normalizeCatIdMentionsInText } from '../utils/cat-mention-handle.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { requireCallbackAuth } from './callback-auth-prehandler.js'; import { buildProposalCardBlock } from './proposal-card-block.js'; diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index e68b4ade29..fb2537eb24 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -54,7 +54,7 @@ import { buildThreadDeepLink } from '../infrastructure/connectors/connector-comm import { createModuleLogger } from '../infrastructure/logger.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { scoreKeywordRelevance, tokenizeKeyword } from '../utils/keyword-relevance.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; import { getFeatureTagId } from './backlog-doc-import.js'; import { enqueueA2ATargets, triggerA2AInvocation } from './callback-a2a-trigger.js'; import { diff --git a/packages/api/src/routes/capabilities-mcp-write.ts b/packages/api/src/routes/capabilities-mcp-write.ts index 42787262db..07d66b8bf1 100644 --- a/packages/api/src/routes/capabilities-mcp-write.ts +++ b/packages/api/src/routes/capabilities-mcp-write.ts @@ -28,9 +28,9 @@ import { requireLocalCapabilityWriteRequest, resolveCapabilityWriteSessionUserId, } from '../config/capabilities/capability-write-guards.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; -import { resolveMainRepoPath } from '../utils/skill-mount.js'; +import { resolveMainRepoPath } from '../utils/skills/skill-mount.js'; import { type McpProbeResult, probeMcpCapability } from './mcp-probe.js'; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/packages/api/src/routes/capabilities.ts b/packages/api/src/routes/capabilities.ts index 493152225b..58a78ac18b 100644 --- a/packages/api/src/routes/capabilities.ts +++ b/packages/api/src/routes/capabilities.ts @@ -59,15 +59,15 @@ import { parsePluginManifest } from '../domains/plugin/plugin-manifest.js'; import { parseManifestSkillMeta, readSkillMeta, type SkillMeta } from '../skills/skill-meta.js'; import { syncAll } from '../skills/skill-sync-all.js'; import { type MountConflict, syncProject } from '../skills/skill-sync-engine.js'; -import { pathsEqual, validateProjectPath } from '../utils/project-path.js'; +import { pathsEqual, validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; import { buildMountPointDirCandidates, buildSkillMountTargets, isSkillMountedAtPoint, resolveMainRepoPath, -} from '../utils/skill-mount.js'; -import { resolveCatCafeSkillsSource } from '../utils/skill-source.js'; +} from '../utils/skills/skill-mount.js'; +import { resolveCatCafeSkillsSource } from '../utils/skills/skill-source.js'; import { type McpProbeResult, probeMcpCapability } from './mcp-probe.js'; // ────────── Capability config helpers ────────── diff --git a/packages/api/src/routes/cats.ts b/packages/api/src/routes/cats.ts index 501e2dadf2..c39b18e892 100644 --- a/packages/api/src/routes/cats.ts +++ b/packages/api/src/routes/cats.ts @@ -34,7 +34,7 @@ import { resolveProjectTemplatePath } from '../config/project-template-path.js'; import { getResolvedCats } from '../config/resolved-cats.js'; import { createRuntimeCat, deleteRuntimeCat, updateRuntimeCat } from '../config/runtime-cat-catalog.js'; import { deleteRuntimeOverride, getRuntimeOverride, setRuntimeOverride } from '../config/session-strategy-overrides.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; const colorSchema = z.object({ diff --git a/packages/api/src/routes/config-secrets.ts b/packages/api/src/routes/config-secrets.ts index 8b477f274a..0f31e42b2e 100644 --- a/packages/api/src/routes/config-secrets.ts +++ b/packages/api/src/routes/config-secrets.ts @@ -15,8 +15,8 @@ import { validateConnectorSecretUpdate, } from '../config/connector-secret-write-guards.js'; import { AuditEventTypes, getEventAuditLog } from '../domains/cats/services/orchestration/EventAuditLog.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; const secretsPatchSchema = z.object({ updates: z diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts index b8572ef040..d4f6dde554 100644 --- a/packages/api/src/routes/config.ts +++ b/packages/api/src/routes/config.ts @@ -37,11 +37,11 @@ import { AuditEventTypes, getEventAuditLog } from '../domains/cats/services/orch // Reading process.env.LOG_DIR here would diverge from logger after a runtime // `PATCH /api/config/env` LOG_DIR edit — env-summary would lie about effective path. import { LOG_DIR_PATH } from '../infrastructure/logger.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; import { configCatOrderRoutes } from './config-cat-order.js'; const patchSchema = z.object({ diff --git a/packages/api/src/routes/connector-hub.ts b/packages/api/src/routes/connector-hub.ts index e74ed93dc1..005fc3f83b 100644 --- a/packages/api/src/routes/connector-hub.ts +++ b/packages/api/src/routes/connector-hub.ts @@ -38,7 +38,7 @@ import { } from '../infrastructure/connectors/plugins/im-connector-manifest.js'; import { resolvePluginsDir } from '../infrastructure/connectors/plugins/plugin-installer.js'; import { normalizeTelegramBotToken } from '../infrastructure/connectors/telegram-token.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/api/src/routes/connector-plugins.ts b/packages/api/src/routes/connector-plugins.ts index eee66ee5cd..c1054a3dac 100644 --- a/packages/api/src/routes/connector-plugins.ts +++ b/packages/api/src/routes/connector-plugins.ts @@ -33,7 +33,7 @@ import { resolvePluginsDir, uninstallPlugin, } from '../infrastructure/connectors/plugins/plugin-installer.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { resolveSessionUserId } from '../utils/request-identity.js'; import { invalidateManifestCache } from './connector-hub.js'; diff --git a/packages/api/src/routes/first-run-quest.ts b/packages/api/src/routes/first-run-quest.ts index 5ba0fac51f..f61c6060d2 100644 --- a/packages/api/src/routes/first-run-quest.ts +++ b/packages/api/src/routes/first-run-quest.ts @@ -14,9 +14,9 @@ import { z } from 'zod'; import { resolveByAccountRef } from '../config/account-resolver.js'; import { detectAvailableClients } from '../domains/cats/services/first-run-quest/client-detection.js'; import type { FirstRunQuestStateV1, IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; -import { resolveCliCommand } from '../utils/cli-resolve.js'; -import { resolveWindowsSpawnPlan } from '../utils/cli-spawn-win.js'; +import { resolveCliCommand } from '../utils/cli/cli-resolve.js'; +import { resolveWindowsSpawnPlan } from '../utils/cli/cli-spawn-win.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; import { resolveUserId } from '../utils/request-identity.js'; const IS_WINDOWS = process.platform === 'win32'; diff --git a/packages/api/src/routes/governance-status.ts b/packages/api/src/routes/governance-status.ts index 953473588f..7458b4df69 100644 --- a/packages/api/src/routes/governance-status.ts +++ b/packages/api/src/routes/governance-status.ts @@ -10,8 +10,8 @@ import { join } from 'node:path'; import { promisify } from 'node:util'; import type { FastifyPluginAsync } from 'fastify'; import { checkGovernancePreflight } from '../config/governance/governance-preflight.js'; -import { findMonorepoRoot } from '../utils/monorepo-root.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { findMonorepoRoot } from '../utils/paths/monorepo-root.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; const execFileAsync = promisify(execFile); @@ -61,7 +61,7 @@ export const governanceStatusRoute: FastifyPluginAsync; } -import { normalizeErrorMessage } from '../utils/normalize-error.js'; +import { normalizeErrorMessage } from '../utils/parsing/normalize-error.js'; import { emitQueueUpdated, enrichQueueEntries } from '../utils/queue-enrichment.js'; import { resolveUserId } from '../utils/request-identity.js'; import { buildGameSeats, parseGameCommand, sanitizeCatIds } from './game-command-interceptor.js'; diff --git a/packages/api/src/routes/mount-rules.ts b/packages/api/src/routes/mount-rules.ts index c96f6968b4..7c2e81f5a4 100644 --- a/packages/api/src/routes/mount-rules.ts +++ b/packages/api/src/routes/mount-rules.ts @@ -30,11 +30,11 @@ import { import { syncAll } from '../skills/skill-sync-all.js'; import { classifyMountPath, syncProject } from '../skills/skill-sync-engine.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; -import { resolvePluginSkillSourcesForProject } from '../utils/plugin-skill-source.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; +import { resolveStartupProjectRoot } from '../utils/paths/startup-root.js'; import { resolveSessionUserId, resolveUserId } from '../utils/request-identity.js'; -import { buildSkillMountTargets, createSkillSymlink, type MountTarget } from '../utils/skill-mount.js'; -import { resolveStartupProjectRoot } from '../utils/startup-root.js'; +import { resolvePluginSkillSourcesForProject } from '../utils/skills/plugin-skill-source.js'; +import { buildSkillMountTargets, createSkillSymlink, type MountTarget } from '../utils/skills/skill-mount.js'; import { resolveSkillsSourceDir } from './skills.js'; const STARTUP_PROJECT_ROOT = resolveStartupProjectRoot(); diff --git a/packages/api/src/routes/plugin-routes.ts b/packages/api/src/routes/plugin-routes.ts index ecbd564eb7..ea0380ae9b 100644 --- a/packages/api/src/routes/plugin-routes.ts +++ b/packages/api/src/routes/plugin-routes.ts @@ -23,7 +23,7 @@ import type { PluginResourceActivator as PluginResourceActivatorType } from '../ import { assertPluginResourceInsideRoot } from '../domains/plugin/PluginResourceActivator.js'; import { loadAllPluginConfigs, resolvePluginEnv, writePluginConfig } from '../domains/plugin/plugin-config-store.js'; import { validateEnvSafety } from '../domains/plugin/plugin-manifest.js'; -import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; +import { resolveActiveProjectRoot } from '../utils/paths/active-project-root.js'; interface PluginRoutesOpts { pluginRegistry: PluginRegistry; diff --git a/packages/api/src/routes/preview.ts b/packages/api/src/routes/preview.ts index bb439db79f..f6be21956b 100644 --- a/packages/api/src/routes/preview.ts +++ b/packages/api/src/routes/preview.ts @@ -5,7 +5,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { AuditEventTypes, type EventAuditLog, getEventAuditLog } from '../domains/cats/services/index.js'; import type { PortDiscoveryService } from '../domains/preview/port-discovery.js'; import { validatePort } from '../domains/preview/port-validator.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; interface PreviewRouteOpts { portDiscovery: PortDiscoveryService; diff --git a/packages/api/src/routes/projects-bootstrap.ts b/packages/api/src/routes/projects-bootstrap.ts index 743a08896c..5c1352ed12 100644 --- a/packages/api/src/routes/projects-bootstrap.ts +++ b/packages/api/src/routes/projects-bootstrap.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync } from 'fastify'; import type { BootstrapProgress, ExpeditionBootstrapService } from '../domains/memory/ExpeditionBootstrapService.js'; import type { IndexStateManager } from '../domains/memory/IndexStateManager.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; interface SocketManagerLike { diff --git a/packages/api/src/routes/projects-mkdir.ts b/packages/api/src/routes/projects-mkdir.ts index 86397015f8..71cd4dd848 100644 --- a/packages/api/src/routes/projects-mkdir.ts +++ b/packages/api/src/routes/projects-mkdir.ts @@ -7,7 +7,7 @@ import { mkdir, stat } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import type { FastifyPluginAsync } from 'fastify'; -import { isUnderAllowedRoot, validateProjectPath } from '../utils/project-path.js'; +import { isUnderAllowedRoot, validateProjectPath } from '../utils/paths/project-path.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; /** Characters not allowed in directory names (cross-platform safe) */ diff --git a/packages/api/src/routes/projects-setup.ts b/packages/api/src/routes/projects-setup.ts index 404cc4e2be..478fd412e5 100644 --- a/packages/api/src/routes/projects-setup.ts +++ b/packages/api/src/routes/projects-setup.ts @@ -8,8 +8,8 @@ import { execFile } from 'node:child_process'; import { readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import type { FastifyPluginAsync } from 'fastify'; -import { findMonorepoRoot } from '../utils/monorepo-root.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { findMonorepoRoot } from '../utils/paths/monorepo-root.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; const VALID_MODES = ['clone', 'init', 'skip'] as const; diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 47dc8080c3..7a2fe98aa7 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -11,7 +11,12 @@ import { homedir } from 'node:os'; import { basename, posix, resolve, win32 } from 'node:path'; import { promisify } from 'node:util'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; -import { getAllowedRoots, isDenylistMode, isUnderAllowedRoot, validateProjectPath } from '../utils/project-path.js'; +import { + getAllowedRoots, + isDenylistMode, + isUnderAllowedRoot, + validateProjectPath, +} from '../utils/paths/project-path.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; const execFileAsync = promisify(execFile); diff --git a/packages/api/src/routes/proposal-approve-overrides.ts b/packages/api/src/routes/proposal-approve-overrides.ts index 40a4c7c450..1c593a222e 100644 --- a/packages/api/src/routes/proposal-approve-overrides.ts +++ b/packages/api/src/routes/proposal-approve-overrides.ts @@ -11,7 +11,7 @@ import type { CatId, ProposalApproveOverrides, ReportingMode, ThreadProposal } from '@cat-cafe/shared'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; /** Parsed approve-body overrides (preferredCats arrives as plain strings from zod). */ export interface ApproveOverridesInput { diff --git a/packages/api/src/routes/push.ts b/packages/api/src/routes/push.ts index 339e26e57f..7e06500674 100644 --- a/packages/api/src/routes/push.ts +++ b/packages/api/src/routes/push.ts @@ -4,7 +4,7 @@ import { requireConnectorWriteOwner, resolveConnectorSessionUserId } from '../co import { AuditEventTypes, getEventAuditLog } from '../domains/cats/services/orchestration/EventAuditLog.js'; import type { PushNotificationService } from '../domains/cats/services/push/PushNotificationService.js'; import type { IPushSubscriptionStore } from '../domains/cats/services/stores/ports/PushSubscriptionStore.js'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; import { describeEndpoint, type PushDeliverySnapshot, diff --git a/packages/api/src/routes/quota.ts b/packages/api/src/routes/quota.ts index a3c8271ee9..4241c2f4d0 100644 --- a/packages/api/src/routes/quota.ts +++ b/packages/api/src/routes/quota.ts @@ -19,7 +19,7 @@ import { promisify } from 'node:util'; import type { FastifyInstance } from 'fastify'; import * as pty from 'node-pty'; import { z } from 'zod'; -import { resolveCliCommand } from '../utils/cli-resolve.js'; +import { resolveCliCommand } from '../utils/cli/cli-resolve.js'; const execFileAsync = promisify(execFile); diff --git a/packages/api/src/routes/ref-audio-upload.ts b/packages/api/src/routes/ref-audio-upload.ts index adcfcbf596..36ad6325c2 100644 --- a/packages/api/src/routes/ref-audio-upload.ts +++ b/packages/api/src/routes/ref-audio-upload.ts @@ -3,7 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import multipart from '@fastify/multipart'; import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; const MAX_REF_AUDIO_BYTES = 10 * 1024 * 1024; diff --git a/packages/api/src/routes/rules.ts b/packages/api/src/routes/rules.ts index 76edb36d7b..6091e1f6b3 100644 --- a/packages/api/src/routes/rules.ts +++ b/packages/api/src/routes/rules.ts @@ -13,7 +13,7 @@ import type { CatCafeConfig } from '@cat-cafe/shared'; import type { FastifyPluginAsync } from 'fastify'; import { getRoster, loadCatConfig, toAllCatConfigs } from '../config/cat-config-loader.js'; import { compileL0ViaSubprocess } from '../domains/cats/services/agents/providers/l0-compiler.js'; -import { getDefaultRootsForPlatform, isPathUnderRoots, validateProjectPath } from '../utils/project-path.js'; +import { getDefaultRootsForPlatform, isPathUnderRoots, validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; function findProjectRoot(): string { diff --git a/packages/api/src/routes/services-lifecycle-helpers.ts b/packages/api/src/routes/services-lifecycle-helpers.ts index dc1508cb35..3d2d794659 100644 --- a/packages/api/src/routes/services-lifecycle-helpers.ts +++ b/packages/api/src/routes/services-lifecycle-helpers.ts @@ -2,7 +2,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import type { ServiceLifecycleRunner, ServiceLifecycleRunResult } from '../domains/services/service-lifecycle.js'; import { isValidModelId } from '../domains/services/service-lifecycle.js'; import { MODEL_ENV_VARS, PORT_ENV_VARS } from '../domains/services/service-manifest.js'; -import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { isDirectLoopbackRequest } from '../utils/network/loopback-request.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; export const DEFAULT_LIFECYCLE_TIMEOUT_MS = 30 * 60 * 1000; diff --git a/packages/api/src/routes/skills-drift.ts b/packages/api/src/routes/skills-drift.ts index 58a403f2c3..571c34f8b0 100644 --- a/packages/api/src/routes/skills-drift.ts +++ b/packages/api/src/routes/skills-drift.ts @@ -20,10 +20,10 @@ import { readMountRules } from '../config/mount/mount-rules-store.js'; import { checkGlobal, checkProject } from '../skills/drift-detector.js'; import { syncDrift } from '../skills/drift-resolver.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; -import { pathsEqual, validateProjectPath } from '../utils/project-path.js'; +import { pathsEqual, validateProjectPath } from '../utils/paths/project-path.js'; +import { resolveStartupProjectRoot } from '../utils/paths/startup-root.js'; import { resolveSessionUserId, resolveUserId } from '../utils/request-identity.js'; -import { resolveCatCafeSkillsSource } from '../utils/skill-source.js'; -import { resolveStartupProjectRoot } from '../utils/startup-root.js'; +import { resolveCatCafeSkillsSource } from '../utils/skills/skill-source.js'; const STARTUP_REPO_ROOT = resolveStartupProjectRoot(); diff --git a/packages/api/src/routes/skills-write.ts b/packages/api/src/routes/skills-write.ts index fc635290eb..8be045584d 100644 --- a/packages/api/src/routes/skills-write.ts +++ b/packages/api/src/routes/skills-write.ts @@ -18,11 +18,11 @@ import { validateSkillName } from '../config/governance/skill-sync.js'; import { readMountRules } from '../config/mount/mount-rules-store.js'; import { classifyMountPath, syncProject } from '../skills/skill-sync-engine.js'; import { resolveOwnerGate } from '../utils/owner-gate.js'; -import { resolvePluginSkillSourcesForProject } from '../utils/plugin-skill-source.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveSessionUserId } from '../utils/request-identity.js'; -import { buildSkillMountTargets, createSkillSymlink, resolveMainRepoPath } from '../utils/skill-mount.js'; -import { listSourceSkillNames } from '../utils/skill-source.js'; +import { resolvePluginSkillSourcesForProject } from '../utils/skills/plugin-skill-source.js'; +import { buildSkillMountTargets, createSkillSymlink, resolveMainRepoPath } from '../utils/skills/skill-mount.js'; +import { listSourceSkillNames } from '../utils/skills/skill-source.js'; import { resolveSkillsSourceDir } from './skills.js'; function requireSkillsWriteAccess(request: FastifyRequest, reply: FastifyReply): { error?: string } { diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts index ad40cec5d5..9547de483c 100644 --- a/packages/api/src/routes/skills.ts +++ b/packages/api/src/routes/skills.ts @@ -15,15 +15,15 @@ import type { FastifyPluginAsync } from 'fastify'; import { readCapabilitiesConfig } from '../config/capabilities/capability-orchestrator.js'; import { readMountRules } from '../config/mount/mount-rules-store.js'; import { parseManifestSkillMeta, resolveSkillMcpStatuses, type SkillMcpDependency } from '../skills/skill-meta.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; import { buildMountPointDirCandidates, buildSkillMountTargets, isSkillMountedAtPoint, resolveMainRepoPath, -} from '../utils/skill-mount.js'; -import { checkStaleness, listSourceSkillNames, type SkillsStaleness } from '../utils/skill-source.js'; +} from '../utils/skills/skill-mount.js'; +import { checkStaleness, listSourceSkillNames, type SkillsStaleness } from '../utils/skills/skill-source.js'; interface SkillMount { claude: boolean; diff --git a/packages/api/src/routes/threads.ts b/packages/api/src/routes/threads.ts index 7aa9c80b09..4bfc554be7 100644 --- a/packages/api/src/routes/threads.ts +++ b/packages/api/src/routes/threads.ts @@ -34,7 +34,7 @@ import type { ThreadRoutingPolicyV1, } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { createModuleLogger } from '../infrastructure/logger.js'; -import { validateProjectPath } from '../utils/project-path.js'; +import { validateProjectPath } from '../utils/paths/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; import { getMultiMentionOrchestrator } from './callback-multi-mention-routes.js'; diff --git a/packages/api/src/skills/drift-detector.ts b/packages/api/src/skills/drift-detector.ts index c26ab6dee5..0be9e4a34f 100644 --- a/packages/api/src/skills/drift-detector.ts +++ b/packages/api/src/skills/drift-detector.ts @@ -19,15 +19,15 @@ import { lstat, readdir, readlink, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { type MountRules, STANDARD_MOUNT_POINT_IDS } from '@cat-cafe/shared'; -import { pathsEqual } from '../utils/project-path.js'; -import { buildSkillMountTargets, isManagedDirectoryLevelSkillsSymlink } from '../utils/skill-mount.js'; +import { pathsEqual } from '../utils/paths/project-path.js'; +import { buildSkillMountTargets, isManagedDirectoryLevelSkillsSymlink } from '../utils/skills/skill-mount.js'; import { canonicalSkillMountPathPolicy, normalizeSkillMountPathPolicy, type SkillMountPathInput, skillAllowsMountPoint, -} from '../utils/skill-mount-policy.js'; -import { listSourceSkillNames } from '../utils/skill-source.js'; +} from '../utils/skills/skill-mount-policy.js'; +import { listSourceSkillNames } from '../utils/skills/skill-source.js'; // ────────── Exported types ────────── diff --git a/packages/api/src/skills/drift-resolver.ts b/packages/api/src/skills/drift-resolver.ts index 1521a5653d..dee60cb927 100644 --- a/packages/api/src/skills/drift-resolver.ts +++ b/packages/api/src/skills/drift-resolver.ts @@ -14,7 +14,7 @@ import { join } from 'node:path'; import type { MountRules } from '@cat-cafe/shared'; import { withCapabilityLock } from '../config/capabilities/capability-orchestrator.js'; import { isValidSkillName } from '../config/governance/skill-sync.js'; -import { buildSkillMountTargets } from '../utils/skill-mount.js'; +import { buildSkillMountTargets } from '../utils/skills/skill-mount.js'; import type { DriftResult } from './drift-detector.js'; import { syncProject } from './skill-sync-engine.js'; diff --git a/packages/api/src/skills/skill-manage.ts b/packages/api/src/skills/skill-manage.ts index f1db813f08..c4390f9bfb 100644 --- a/packages/api/src/skills/skill-manage.ts +++ b/packages/api/src/skills/skill-manage.ts @@ -12,7 +12,7 @@ import { dirname, join, relative } from 'node:path'; import { type CapabilityEntry, type MountRules, STANDARD_MOUNT_POINT_IDS } from '@cat-cafe/shared'; import { readCapabilitiesConfig, writeCapabilitiesConfig } from '../config/capabilities/capability-orchestrator.js'; -import { buildSkillMountTargets, createSkillSymlink } from '../utils/skill-mount.js'; +import { buildSkillMountTargets, createSkillSymlink } from '../utils/skills/skill-mount.js'; import { parseManifestSkillMeta, readSkillMeta } from './skill-meta.js'; import { classifyMountPath, type MountConflict } from './skill-sync-engine.js'; diff --git a/packages/api/src/skills/skill-sync-engine.ts b/packages/api/src/skills/skill-sync-engine.ts index c2f57fab73..035eca65a2 100644 --- a/packages/api/src/skills/skill-sync-engine.ts +++ b/packages/api/src/skills/skill-sync-engine.ts @@ -15,13 +15,13 @@ import { resolveEffectiveSkillMountPaths, validateSkillName, } from '../config/governance/skill-sync.js'; -import { pathsEqual } from '../utils/project-path.js'; +import { pathsEqual } from '../utils/paths/project-path.js'; import { buildSkillMountTargets, createSkillSymlink, isManagedDirectoryLevelSkillsSymlink, -} from '../utils/skill-mount.js'; -import { computeSourceManifestHash, listSourceSkillNames } from '../utils/skill-source.js'; +} from '../utils/skills/skill-mount.js'; +import { computeSourceManifestHash, listSourceSkillNames } from '../utils/skills/skill-source.js'; import { updateConfigAfterSync, writeSkillsSyncState } from './skill-sync-config.js'; function symlinkTargetFor(linkPath: string, sourcePath: string): string { diff --git a/packages/api/src/utils/cli-diagnostics.ts b/packages/api/src/utils/cli/cli-diagnostics.ts similarity index 100% rename from packages/api/src/utils/cli-diagnostics.ts rename to packages/api/src/utils/cli/cli-diagnostics.ts diff --git a/packages/api/src/utils/cli-error-patterns.ts b/packages/api/src/utils/cli/cli-error-patterns.ts similarity index 100% rename from packages/api/src/utils/cli-error-patterns.ts rename to packages/api/src/utils/cli/cli-error-patterns.ts diff --git a/packages/api/src/utils/cli-format.ts b/packages/api/src/utils/cli/cli-format.ts similarity index 100% rename from packages/api/src/utils/cli-format.ts rename to packages/api/src/utils/cli/cli-format.ts diff --git a/packages/api/src/utils/cli-resolve.ts b/packages/api/src/utils/cli/cli-resolve.ts similarity index 100% rename from packages/api/src/utils/cli-resolve.ts rename to packages/api/src/utils/cli/cli-resolve.ts diff --git a/packages/api/src/utils/cli-spawn-win.ts b/packages/api/src/utils/cli/cli-spawn-win.ts similarity index 100% rename from packages/api/src/utils/cli-spawn-win.ts rename to packages/api/src/utils/cli/cli-spawn-win.ts diff --git a/packages/api/src/utils/cli-spawn.ts b/packages/api/src/utils/cli/cli-spawn.ts similarity index 98% rename from packages/api/src/utils/cli-spawn.ts rename to packages/api/src/utils/cli/cli-spawn.ts index 687a3ca8c4..fc1f3eaf46 100644 --- a/packages/api/src/utils/cli-spawn.ts +++ b/packages/api/src/utils/cli/cli-spawn.ts @@ -9,9 +9,11 @@ import { dirname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Span } from '@opentelemetry/api'; import { context, SpanStatusCode, trace } from '@opentelemetry/api'; -import { createModuleLogger } from '../infrastructure/logger.js'; -import { registerLivenessProbe, unregisterLivenessProbe } from '../infrastructure/telemetry/instruments.js'; -import { emitOtelLog } from '../infrastructure/telemetry/otel-logger.js'; +import { createModuleLogger } from '../../infrastructure/logger.js'; +import { registerLivenessProbe, unregisterLivenessProbe } from '../../infrastructure/telemetry/instruments.js'; +import { emitOtelLog } from '../../infrastructure/telemetry/otel-logger.js'; +import { isParseError, parseNDJSON } from '../parsing/ndjson-parser.js'; +import { ProcessLivenessProbe } from '../process/ProcessLivenessProbe.js'; import { buildCliDiagnostics, buildCliExitDiagnostic, @@ -23,8 +25,6 @@ import { invalidateCliCommand } from './cli-resolve.js'; import { resolveWindowsSpawnPlan } from './cli-spawn-win.js'; import { resolveCliTimeoutMs } from './cli-timeout.js'; import type { ChildProcessLike, CliSpawnOptions, SpawnFn } from './cli-types.js'; -import { isParseError, parseNDJSON } from './ndjson-parser.js'; -import { ProcessLivenessProbe } from './ProcessLivenessProbe.js'; import { sanitizeCliStderr } from './sanitize-cli-stderr.js'; const log = createModuleLogger('cli-spawn'); @@ -897,7 +897,9 @@ export function isCliTimeout(value: unknown): value is { /** * Type guard for liveness warning events from ProcessLivenessProbe (F118 Phase C) */ -export function isLivenessWarning(value: unknown): value is import('./ProcessLivenessProbe.js').LivenessWarningEvent { +export function isLivenessWarning( + value: unknown, +): value is import('../process/ProcessLivenessProbe.js').LivenessWarningEvent { return ( typeof value === 'object' && value !== null && diff --git a/packages/api/src/utils/cli-supervisor.ts b/packages/api/src/utils/cli/cli-supervisor.ts similarity index 100% rename from packages/api/src/utils/cli-supervisor.ts rename to packages/api/src/utils/cli/cli-supervisor.ts diff --git a/packages/api/src/utils/cli-timeout.ts b/packages/api/src/utils/cli/cli-timeout.ts similarity index 100% rename from packages/api/src/utils/cli-timeout.ts rename to packages/api/src/utils/cli/cli-timeout.ts diff --git a/packages/api/src/utils/cli-types.ts b/packages/api/src/utils/cli/cli-types.ts similarity index 98% rename from packages/api/src/utils/cli-types.ts rename to packages/api/src/utils/cli/cli-types.ts index 2012cf05eb..c35735b09b 100644 --- a/packages/api/src/utils/cli-types.ts +++ b/packages/api/src/utils/cli/cli-types.ts @@ -6,7 +6,7 @@ import type { Readable, Writable } from 'node:stream'; import type { CatId } from '@cat-cafe/shared'; import type { Span } from '@opentelemetry/api'; -import type { AgentMessage } from '../domains/cats/services/types.js'; +import type { AgentMessage } from '../../domains/cats/services/types.js'; /** * Options for spawning a CLI process diff --git a/packages/api/src/utils/sanitize-cli-stderr.ts b/packages/api/src/utils/cli/sanitize-cli-stderr.ts similarity index 100% rename from packages/api/src/utils/sanitize-cli-stderr.ts rename to packages/api/src/utils/cli/sanitize-cli-stderr.ts diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 25224509f8..dac9436e6b 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -3,15 +3,15 @@ * CLI 子进程解析工具导出 */ -export { formatCliExitError } from './cli-format.js'; -export type { CliSpawnerDeps } from './cli-spawn.js'; -export { isCliError, KILL_GRACE_MS, spawnCli } from './cli-spawn.js'; +export { formatCliExitError } from './cli/cli-format.js'; +export type { CliSpawnerDeps } from './cli/cli-spawn.js'; +export { isCliError, KILL_GRACE_MS, spawnCli } from './cli/cli-spawn.js'; export type { ChildProcessLike, CliSpawnOptions, CliTransformer, SpawnFn, -} from './cli-types.js'; -export { isParseError, parseNDJSON } from './ndjson-parser.js'; -export { normalizeErrorMessage } from './normalize-error.js'; -export { isUnderAllowedRoot, validateProjectPath } from './project-path.js'; +} from './cli/cli-types.js'; +export { isParseError, parseNDJSON } from './parsing/ndjson-parser.js'; +export { normalizeErrorMessage } from './parsing/normalize-error.js'; +export { isUnderAllowedRoot, validateProjectPath } from './paths/project-path.js'; diff --git a/packages/api/src/utils/image-storage.ts b/packages/api/src/utils/media/image-storage.ts similarity index 100% rename from packages/api/src/utils/image-storage.ts rename to packages/api/src/utils/media/image-storage.ts diff --git a/packages/api/src/utils/upload-paths.ts b/packages/api/src/utils/media/upload-paths.ts similarity index 93% rename from packages/api/src/utils/upload-paths.ts rename to packages/api/src/utils/media/upload-paths.ts index b70d93bff8..f4e37df618 100644 --- a/packages/api/src/utils/upload-paths.ts +++ b/packages/api/src/utils/media/upload-paths.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const THIS_DIR = dirname(fileURLToPath(import.meta.url)); -const MODULE_DEFAULT_UPLOAD_DIR = resolve(THIS_DIR, '../../uploads'); +const MODULE_DEFAULT_UPLOAD_DIR = resolve(THIS_DIR, '../../../uploads'); /** * Resolve the upload directory. diff --git a/packages/api/src/utils/loopback-request.ts b/packages/api/src/utils/network/loopback-request.ts similarity index 100% rename from packages/api/src/utils/loopback-request.ts rename to packages/api/src/utils/network/loopback-request.ts diff --git a/packages/api/src/utils/tcp-probe.ts b/packages/api/src/utils/network/tcp-probe.ts similarity index 100% rename from packages/api/src/utils/tcp-probe.ts rename to packages/api/src/utils/network/tcp-probe.ts diff --git a/packages/api/src/utils/jsonl-tail-reader.ts b/packages/api/src/utils/parsing/jsonl-tail-reader.ts similarity index 100% rename from packages/api/src/utils/jsonl-tail-reader.ts rename to packages/api/src/utils/parsing/jsonl-tail-reader.ts diff --git a/packages/api/src/utils/ndjson-parser.ts b/packages/api/src/utils/parsing/ndjson-parser.ts similarity index 100% rename from packages/api/src/utils/ndjson-parser.ts rename to packages/api/src/utils/parsing/ndjson-parser.ts diff --git a/packages/api/src/utils/normalize-error.ts b/packages/api/src/utils/parsing/normalize-error.ts similarity index 100% rename from packages/api/src/utils/normalize-error.ts rename to packages/api/src/utils/parsing/normalize-error.ts diff --git a/packages/api/src/utils/active-project-root.ts b/packages/api/src/utils/paths/active-project-root.ts similarity index 100% rename from packages/api/src/utils/active-project-root.ts rename to packages/api/src/utils/paths/active-project-root.ts diff --git a/packages/api/src/utils/is-same-repo.ts b/packages/api/src/utils/paths/is-same-repo.ts similarity index 100% rename from packages/api/src/utils/is-same-repo.ts rename to packages/api/src/utils/paths/is-same-repo.ts diff --git a/packages/api/src/utils/local-override.ts b/packages/api/src/utils/paths/local-override.ts similarity index 100% rename from packages/api/src/utils/local-override.ts rename to packages/api/src/utils/paths/local-override.ts diff --git a/packages/api/src/utils/memory-root.ts b/packages/api/src/utils/paths/memory-root.ts similarity index 100% rename from packages/api/src/utils/memory-root.ts rename to packages/api/src/utils/paths/memory-root.ts diff --git a/packages/api/src/utils/monorepo-root.ts b/packages/api/src/utils/paths/monorepo-root.ts similarity index 100% rename from packages/api/src/utils/monorepo-root.ts rename to packages/api/src/utils/paths/monorepo-root.ts diff --git a/packages/api/src/utils/project-path.ts b/packages/api/src/utils/paths/project-path.ts similarity index 100% rename from packages/api/src/utils/project-path.ts rename to packages/api/src/utils/paths/project-path.ts diff --git a/packages/api/src/utils/startup-root.ts b/packages/api/src/utils/paths/startup-root.ts similarity index 100% rename from packages/api/src/utils/startup-root.ts rename to packages/api/src/utils/paths/startup-root.ts diff --git a/packages/api/src/utils/privileged-route-guard.ts b/packages/api/src/utils/privileged-route-guard.ts index 139149c344..d07912758a 100644 --- a/packages/api/src/utils/privileged-route-guard.ts +++ b/packages/api/src/utils/privileged-route-guard.ts @@ -1,5 +1,5 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; -import { isDirectLoopbackRequest } from './loopback-request.js'; +import { isDirectLoopbackRequest } from './network/loopback-request.js'; import { resolveOwnerGate } from './owner-gate.js'; export type PrivilegedRouteGuardResult = { ok: true; userId: string } | { ok: false; response: { error: string } }; diff --git a/packages/api/src/utils/ProcessLivenessProbe.ts b/packages/api/src/utils/process/ProcessLivenessProbe.ts similarity index 100% rename from packages/api/src/utils/ProcessLivenessProbe.ts rename to packages/api/src/utils/process/ProcessLivenessProbe.ts diff --git a/packages/api/src/utils/orphan-chrome-cleaner.ts b/packages/api/src/utils/process/orphan-chrome-cleaner.ts similarity index 97% rename from packages/api/src/utils/orphan-chrome-cleaner.ts rename to packages/api/src/utils/process/orphan-chrome-cleaner.ts index ae27e7dfe5..8608c8dbf9 100644 --- a/packages/api/src/utils/orphan-chrome-cleaner.ts +++ b/packages/api/src/utils/process/orphan-chrome-cleaner.ts @@ -52,6 +52,7 @@ function isChromeBinary(args: string): boolean { args.startsWith('/Applications/Chromium.app/') || /^\/(?:usr|opt|snap)\S*\/(?:google-chrome|chromium|chrome)/.test(args) || // LL-056 ext: user-local cached Chromium (rod / puppeteer / playwright auto-downloads) + /^\/\S*\/Chromium(?:\s|$)/.test(args) || /^\/\S*\/Chromium\.app\/Contents\/MacOS\/Chromium(?:\s|$)/.test(args) || // LL-056 ext (Linux): Playwright/Puppeteer/Rod cache Chromium in chrome-linux[64]/ subdir /^\/\S*\/chrome-linux(?:64)?\/(?:chrome|headless_shell)(?:\s|$)/.test(args) || @@ -59,7 +60,8 @@ function isChromeBinary(args: string): boolean { /^\/\S*\/chrome-headless-shell(?:\s|$)/.test(args) || // LL-056 ext: cached macOS Chromium helper processes (Renderer/GPU/Network/Plugin). // Scoped to binary-path prefix so prompt text can't false-match. - /\/Chromium\.app\/Contents\/Frameworks\//.test(binaryPath) + /\/Chromium\.app\/Contents\/Frameworks\//.test(binaryPath) || + /\/Chromium Framework\.framework\/.+\/Helpers\/Chromium Helper/.test(binaryPath) ); } diff --git a/packages/api/src/utils/plugin-skill-source.ts b/packages/api/src/utils/skills/plugin-skill-source.ts similarity index 97% rename from packages/api/src/utils/plugin-skill-source.ts rename to packages/api/src/utils/skills/plugin-skill-source.ts index 69d3903d19..30b0c85bfb 100644 --- a/packages/api/src/utils/plugin-skill-source.ts +++ b/packages/api/src/utils/skills/plugin-skill-source.ts @@ -10,8 +10,8 @@ import { existsSync, realpathSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { CapabilitiesConfig } from '@cat-cafe/shared'; -import { resolvePluginResourcePath, resourcePathBasename } from '../domains/plugin/PluginRegistry.js'; -import { parsePluginManifest } from '../domains/plugin/plugin-manifest.js'; +import { resolvePluginResourcePath, resourcePathBasename } from '../../domains/plugin/PluginRegistry.js'; +import { parsePluginManifest } from '../../domains/plugin/plugin-manifest.js'; export interface PluginSkillInfo { pluginId: string; diff --git a/packages/api/src/utils/skill-mount-policy.ts b/packages/api/src/utils/skills/skill-mount-policy.ts similarity index 100% rename from packages/api/src/utils/skill-mount-policy.ts rename to packages/api/src/utils/skills/skill-mount-policy.ts diff --git a/packages/api/src/utils/skill-mount.ts b/packages/api/src/utils/skills/skill-mount.ts similarity index 98% rename from packages/api/src/utils/skill-mount.ts rename to packages/api/src/utils/skills/skill-mount.ts index de143835a0..db11b68744 100644 --- a/packages/api/src/utils/skill-mount.ts +++ b/packages/api/src/utils/skills/skill-mount.ts @@ -1,8 +1,8 @@ import { lstat, readlink, realpath, symlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { DEFAULT_MOUNT_RULES, type MountRules, STANDARD_MOUNT_POINT_IDS } from '@cat-cafe/shared'; -import { pathsEqual } from './project-path.js'; -import { resolveStartupProjectRoot } from './startup-root.js'; +import { pathsEqual } from '../paths/project-path.js'; +import { resolveStartupProjectRoot } from '../paths/startup-root.js'; export type SkillMountPointKey = 'claude' | 'codex' | 'gemini' | 'kimi'; diff --git a/packages/api/src/utils/skill-source.ts b/packages/api/src/utils/skills/skill-source.ts similarity index 95% rename from packages/api/src/utils/skill-source.ts rename to packages/api/src/utils/skills/skill-source.ts index fd25b2b2f9..53820bdf8e 100644 --- a/packages/api/src/utils/skill-source.ts +++ b/packages/api/src/utils/skills/skill-source.ts @@ -3,8 +3,8 @@ import { existsSync } from 'node:fs'; import { readdir, stat } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { readCapabilitiesConfig } from '../config/capabilities/capability-orchestrator.js'; -import { readSkillsSyncState } from '../skills/skill-sync-config.js'; +import { readCapabilitiesConfig } from '../../config/capabilities/capability-orchestrator.js'; +import { readSkillsSyncState } from '../../skills/skill-sync-config.js'; function resolveCurrentWorktreeSkillsSource(): string { let dir = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/api/test/acp/acp-client-windows-spawn.test.js b/packages/api/test/acp/acp-client-windows-spawn.test.js index effc137e3e..95e9c25963 100644 --- a/packages/api/test/acp/acp-client-windows-spawn.test.js +++ b/packages/api/test/acp/acp-client-windows-spawn.test.js @@ -15,7 +15,7 @@ import { PassThrough } from 'node:stream'; import { afterEach, describe, it, mock } from 'node:test'; const ACP_CLIENT_MODULE = '../../dist/domains/cats/services/agents/providers/acp/AcpClient.js'; -const { findSystemNode } = await import('../../dist/utils/cli-spawn-win.js'); +const { findSystemNode } = await import('../../dist/utils/cli/cli-spawn-win.js'); const require = createRequire(import.meta.url); const childProcess = require('node:child_process'); const INIT_RESULT = { diff --git a/packages/api/test/active-project-root.test.js b/packages/api/test/active-project-root.test.js index 36ad8d8f3c..56f6b131cb 100644 --- a/packages/api/test/active-project-root.test.js +++ b/packages/api/test/active-project-root.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -const { resolveActiveProjectRoot } = await import('../dist/utils/active-project-root.js'); +const { resolveActiveProjectRoot } = await import('../dist/utils/paths/active-project-root.js'); describe('resolveActiveProjectRoot', () => { const savedEnv = {}; diff --git a/packages/api/test/agent-provider-2b-p1-fixes.test.js b/packages/api/test/agent-provider-2b-p1-fixes.test.js index f7d3bd6390..4c006e2d0d 100644 --- a/packages/api/test/agent-provider-2b-p1-fixes.test.js +++ b/packages/api/test/agent-provider-2b-p1-fixes.test.js @@ -130,6 +130,32 @@ describe('P1.3 — snapshot must include binding + active-cat mention patterns', ); }); + it('includes manifest providerId in existing routeable identities before descriptor name', () => { + const candidate = makeRow().descriptor; + const otherPluginEntry = { + id: 'other-plugin', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'other-plugin', + agentProvider: { + ...candidate, + name: 'my-provider', + providerId: 'openai', + }, + }; + const snapshot = buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: { version: 1, capabilities: [otherPluginEntry] }, + activeCatConfigs: {}, + templateBaselineIds: new Set(), + hasProviderTransportConfig: () => false, + candidatePluginId: 'clowder-code', + candidateCapId: 'clowder-code', + }); + assert.ok(snapshot.existingRouteableIdentities.has('openai')); + assert.equal(snapshot.existingRouteableIdentities.has('my-provider'), false); + }); + it('blocks @-alias collision with an active cat (mentionPatterns), not just the cat-id literal', () => { const snapshot = buildAgentProviderAdmissionSnapshot({ capabilitiesConfig: { version: 1, capabilities: [] }, diff --git a/packages/api/test/agent-provider-approval-service.test.js b/packages/api/test/agent-provider-approval-service.test.js index 355f7f369b..007e21d120 100644 --- a/packages/api/test/agent-provider-approval-service.test.js +++ b/packages/api/test/agent-provider-approval-service.test.js @@ -183,6 +183,28 @@ describe('AgentProviderApprovalService.approveRouteable', () => { assert.equal(persisted.routeableApproved, false); }); + it('uses manifest providerId instead of descriptor name for admission', async () => { + const row = makeCapabilityRow({ + resourceOverrides: { + name: 'my-provider', + providerId: 'openai', + }, + }); + const { service, store } = makeService({ capabilities: [row] }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'safe-plugin-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'openai'); + + const persisted = store.get().capabilities[0].agentProvider; + assert.equal(persisted.routeable, false); + assert.equal(persisted.routeableApproved, false); + }); + it('propagates active-cat collision', async () => { const { service } = makeService({ buildSnapshot: () => ({ diff --git a/packages/api/test/agent-router.test.js b/packages/api/test/agent-router.test.js index d4d6bb1967..50859d728e 100644 --- a/packages/api/test/agent-router.test.js +++ b/packages/api/test/agent-router.test.js @@ -1474,7 +1474,7 @@ describe('AgentRouter', () => { test('passes workingDirectory when thread has non-default projectPath', async () => { const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); - const { findMonorepoRoot } = await import('../dist/utils/monorepo-root.js'); + const { findMonorepoRoot } = await import('../dist/utils/paths/monorepo-root.js'); // Keep this path inside the host repo: this test covers workingDirectory // propagation, not the external-project governance gate. const projectPath = findMonorepoRoot(); diff --git a/packages/api/test/capabilities-route.test.js b/packages/api/test/capabilities-route.test.js index cc68a98f4e..1197558459 100644 --- a/packages/api/test/capabilities-route.test.js +++ b/packages/api/test/capabilities-route.test.js @@ -1392,7 +1392,7 @@ describe('GET /api/capabilities (Fastify)', () => { it('realigns stale managed cat-cafe MCP paths to the stable main repo root on GET', async () => { const Fastify = (await import('fastify')).default; const { capabilitiesRoutes } = await import('../dist/routes/capabilities.js'); - const { resolveMainRepoPath } = await import('../dist/utils/skill-mount.js'); + const { resolveMainRepoPath } = await import('../dist/utils/skills/skill-mount.js'); const projectDir = join('/tmp', `cap-route-test-stale-cat-cafe-path-${Date.now()}`); await mkdir(projectDir, { recursive: true }); diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index ac17353273..bb09b81940 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -1015,14 +1015,14 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.deepEqual(fable.mentionPatterns, ['@fable5', '@fable-5', '@claude-fable-5', '@宪宪5', '@布偶猫5']); }); - it('total cat count is 16 (opus + sonnet + opus-45 + opus-47 + fable-5 + codex + gpt52 + spark + gemini + gemini25 + gemini35 + kimi + dare + antigravity + antig-opus + opencode)', () => { + it('total cat count is 17 (opus + sonnet + opus-45 + opus-47 + fable-5 + codex + gpt52 + spark + gemini + gemini25 + gemini35 + kimi + dare + antigravity + antig-opus + opencode + catagent)', () => { // Use template directly to avoid catalog overlay pollution from earlier tests const templatePath = process.env.CAT_TEMPLATE_PATH ?? resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'cat-template.json'); const config = loadCatConfig(templatePath); const all = toAllCatConfigs(config); - assert.equal(Object.keys(all).length, 16); + assert.equal(Object.keys(all).length, 17); assert.ok(all.opus); assert.ok(all.sonnet); assert.ok(all['opus-45']); @@ -1039,6 +1039,7 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.ok(all.antigravity); // F061: Bengal cat (Antigravity CDP bridge) assert.ok(all['antig-opus']); // F061: Bengal cat Claude variant assert.ok(all.opencode); // F105: OpenCode external agent + assert.ok(all.catagent); // F245: Catagent external provider }); it('antigravity variants have no cli config (F061 Bridge replaces CDP)', () => { diff --git a/packages/api/test/catagent-phase-e.test.js b/packages/api/test/catagent-phase-e.test.js index 742cfb2608..ca68831ad2 100644 --- a/packages/api/test/catagent-phase-e.test.js +++ b/packages/api/test/catagent-phase-e.test.js @@ -106,7 +106,11 @@ function openAITextTurnEvents(text, finishReason = 'stop', promptTokens = 10, co id: `chatcmpl-${Date.now()}`, choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: finishReason }], }, - { id: `chatcmpl-${Date.now()}`, choices: [], usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens } }, + { + id: `chatcmpl-${Date.now()}`, + choices: [], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens }, + }, '[DONE]', ]; } @@ -487,7 +491,10 @@ describe('G2 Axis 5: OpenAI Chat protocol e2e', () => { }); const msgs = await collect(svc.invoke('hi')); - const text = msgs.filter((msg) => msg.type === 'text').map((msg) => msg.content).join(''); + const text = msgs + .filter((msg) => msg.type === 'text') + .map((msg) => msg.content) + .join(''); assert.equal(text, 'Hello from OpenAI'); assert.ok(msgs.some((msg) => msg.type === 'done')); assert.equal(captures[0].url, 'https://proxy.example/v1/chat/completions'); diff --git a/packages/api/test/catagent-protocol-factory.test.js b/packages/api/test/catagent-protocol-factory.test.js index 0c494992ba..42b975b322 100644 --- a/packages/api/test/catagent-protocol-factory.test.js +++ b/packages/api/test/catagent-protocol-factory.test.js @@ -11,13 +11,16 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -const { createCatAgentProtocolAdapter, CatAgentProtocolUnknownError } = - await import('../dist/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.js'); +const { createCatAgentProtocolAdapter, CatAgentProtocolUnknownError } = await import( + '../dist/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.js' +); const { AnthropicMessagesAdapter } = await import( '../dist/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.js' ); -const { OpenAIChatAdapter } = await import('../dist/domains/cats/services/agents/providers/catagent/openai-chat-adapter.js'); +const { OpenAIChatAdapter } = await import( + '../dist/domains/cats/services/agents/providers/catagent/openai-chat-adapter.js' +); describe('createCatAgentProtocolAdapter dispatch (AC-G17 / KD-20 fail-closed)', () => { test('null catConfig → AnthropicMessagesAdapter (legacy/test path)', () => { diff --git a/packages/api/test/catagent-vendor-neutrality.test.js b/packages/api/test/catagent-vendor-neutrality.test.js index b219d4abe3..fc217ec118 100644 --- a/packages/api/test/catagent-vendor-neutrality.test.js +++ b/packages/api/test/catagent-vendor-neutrality.test.js @@ -17,7 +17,18 @@ function stripCommentsAndStrings(source) { test('AC-G12/AC-G27: CatAgentService stays vendor-neutral in code identifiers', () => { const source = readFileSync( - join(__dirname, '..', 'src', 'domains', 'cats', 'services', 'agents', 'providers', 'catagent', 'CatAgentService.ts'), + join( + __dirname, + '..', + 'src', + 'domains', + 'cats', + 'services', + 'agents', + 'providers', + 'catagent', + 'CatAgentService.ts', + ), 'utf-8', ); const stripped = stripCommentsAndStrings(source); diff --git a/packages/api/test/cli-diagnostics-unknown-raw.test.js b/packages/api/test/cli-diagnostics-unknown-raw.test.js index 23254155be..ffebc921c1 100644 --- a/packages/api/test/cli-diagnostics-unknown-raw.test.js +++ b/packages/api/test/cli-diagnostics-unknown-raw.test.js @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -const { buildCliDiagnostics } = await import('../dist/utils/cli-diagnostics.js'); +const { buildCliDiagnostics } = await import('../dist/utils/cli/cli-diagnostics.js'); const debugRef = { command: 'test-cli', exitCode: 1, exitSignal: undefined, durationMs: 100 }; diff --git a/packages/api/test/cli-diagnostics.test.js b/packages/api/test/cli-diagnostics.test.js index 2913b80121..db08e60738 100644 --- a/packages/api/test/cli-diagnostics.test.js +++ b/packages/api/test/cli-diagnostics.test.js @@ -7,8 +7,8 @@ import { buildCliExitDiagnostic, buildSilentCompletionDiagnostic, formatCliStderrForLog, -} from '../dist/utils/cli-diagnostics.js'; -import { maybeCollectStreamError } from '../dist/utils/cli-spawn.js'; +} from '../dist/utils/cli/cli-diagnostics.js'; +import { maybeCollectStreamError } from '../dist/utils/cli/cli-spawn.js'; const baseRef = { command: 'codex', exitCode: 1, signal: null, invocationId: 'inv-1' }; diff --git a/packages/api/test/cli-error-patterns.test.js b/packages/api/test/cli-error-patterns.test.js index 48864245c8..756e10ede4 100644 --- a/packages/api/test/cli-error-patterns.test.js +++ b/packages/api/test/cli-error-patterns.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert'; import test from 'node:test'; -import { classifyCliError } from '../dist/utils/cli-diagnostics.js'; +import { classifyCliError } from '../dist/utils/cli/cli-diagnostics.js'; const fixtures = [ // Existing (must regress — predates F212, must keep behavior) diff --git a/packages/api/test/cli-resolve.test.js b/packages/api/test/cli-resolve.test.js index 12c4a9d7ba..b85e46703b 100644 --- a/packages/api/test/cli-resolve.test.js +++ b/packages/api/test/cli-resolve.test.js @@ -5,7 +5,7 @@ import { join } from 'node:path'; import test from 'node:test'; const { resolveCliCommand, resolveCliCommandOrBare, formatCliNotFoundError, invalidateCliCommand } = await import( - '../dist/utils/cli-resolve.js' + '../dist/utils/cli/cli-resolve.js' ); // --- formatCliNotFoundError --- diff --git a/packages/api/test/cli-spawn-busy-silent-stall.test.js b/packages/api/test/cli-spawn-busy-silent-stall.test.js index 85d103b181..ff14074bcf 100644 --- a/packages/api/test/cli-spawn-busy-silent-stall.test.js +++ b/packages/api/test/cli-spawn-busy-silent-stall.test.js @@ -17,8 +17,8 @@ import { PassThrough } from 'node:stream'; import { test } from 'node:test'; import { clearTimeout as clearKeepAliveTimeout, setTimeout as setKeepAliveTimeout } from 'node:timers'; -const { spawnCli, isCliTimeout } = await import('../dist/utils/cli-spawn.js'); -const { ProcessLivenessProbe } = await import('../dist/utils/ProcessLivenessProbe.js'); +const { spawnCli, isCliTimeout } = await import('../dist/utils/cli/cli-spawn.js'); +const { ProcessLivenessProbe } = await import('../dist/utils/process/ProcessLivenessProbe.js'); function createMockProcess(opts = {}) { const { exitOnKill = true, exitCode = null, pid = 12345, autoCloseOnExit = true } = opts; @@ -68,7 +68,7 @@ test('stderr handler should NOT call probe.notifyActivity (structural contract)' // Read the cli-spawn source and check whether stderr handler calls notifyActivity const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); - const source = await readFile(join(import.meta.dirname, '..', 'src', 'utils', 'cli-spawn.ts'), 'utf-8'); + const source = await readFile(join(import.meta.dirname, '..', 'src', 'utils', 'cli', 'cli-spawn.ts'), 'utf-8'); // Find the stderr handler block using indexOf (regex lazy match stops too early) const stderrIdx = source.indexOf("child.stderr?.on('data'"); @@ -116,7 +116,7 @@ test('stderr handler should NOT call probe.notifyActivity (structural contract)' test('resetTimeout must NOT have early wall-clock cap (pure inactivity timer)', async () => { const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); - const source = await readFile(join(import.meta.dirname, '..', 'src', 'utils', 'cli-spawn.ts'), 'utf-8'); + const source = await readFile(join(import.meta.dirname, '..', 'src', 'utils', 'cli', 'cli-spawn.ts'), 'utf-8'); // Find the resetTimeout function body const resetTimeoutMatch = source.match(/const resetTimeout\s*=\s*\(\)\s*(?::\s*void)?\s*=>\s*\{([\s\S]*?)\n {2}\};/); diff --git a/packages/api/test/cli-spawn-native-exe.test.js b/packages/api/test/cli-spawn-native-exe.test.js index b3c4f17236..a5a11e50a7 100644 --- a/packages/api/test/cli-spawn-native-exe.test.js +++ b/packages/api/test/cli-spawn-native-exe.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -const { shouldDirectSpawnNativeExe } = await import('../dist/utils/cli-spawn-win.js'); +const { shouldDirectSpawnNativeExe } = await import('../dist/utils/cli/cli-spawn-win.js'); describe('shouldDirectSpawnNativeExe', () => { const trueExists = () => true; diff --git a/packages/api/test/cli-spawn-win.test.js b/packages/api/test/cli-spawn-win.test.js index 2c3ef20daa..b0df9d13f2 100644 --- a/packages/api/test/cli-spawn-win.test.js +++ b/packages/api/test/cli-spawn-win.test.js @@ -5,7 +5,7 @@ import { join } from 'node:path'; import test from 'node:test'; const { resolveCmdShimScript, resolveWindowsShimSpawn, escapeCmdArg, extractBareName, parseShimFile, findSystemNode } = - await import('../dist/utils/cli-spawn-win.js'); + await import('../dist/utils/cli/cli-spawn-win.js'); test( 'resolveCmdShimScript supports %dp0 shims and keeps scanning where results until one resolves', diff --git a/packages/api/test/cli-spawn.test.js b/packages/api/test/cli-spawn.test.js index 14f205d620..d87e6f92f3 100644 --- a/packages/api/test/cli-spawn.test.js +++ b/packages/api/test/cli-spawn.test.js @@ -23,10 +23,10 @@ const { KILL_GRACE_MS, SEMANTIC_COMPLETION_GRACE_MS, resolveCliSupervisorNodeArgs, -} = await import('../dist/utils/cli-spawn.js'); -const { DEFAULT_CLI_TIMEOUT_MS } = await import('../dist/utils/cli-timeout.js'); -const { isParseError } = await import('../dist/utils/ndjson-parser.js'); -const { ProcessLivenessProbe } = await import('../dist/utils/ProcessLivenessProbe.js'); +} = await import('../dist/utils/cli/cli-spawn.js'); +const { DEFAULT_CLI_TIMEOUT_MS } = await import('../dist/utils/cli/cli-timeout.js'); +const { isParseError } = await import('../dist/utils/parsing/ndjson-parser.js'); +const { ProcessLivenessProbe } = await import('../dist/utils/process/ProcessLivenessProbe.js'); /** Helper: collect all items from async iterable */ async function collect(iterable) { @@ -281,7 +281,7 @@ test( async () => { const tempDir = await mkdtemp(join(tmpdir(), 'cat-cafe-cli-supervisor-')); const markerPath = join(tempDir, 'terminated.txt'); - const supervisorPath = fileURLToPath(new URL('../dist/utils/cli-supervisor.js', import.meta.url)); + const supervisorPath = fileURLToPath(new URL('../dist/utils/cli/cli-supervisor.js', import.meta.url)); const childScript = [ 'const fs = require("node:fs");', `process.on("SIGTERM", () => { fs.writeFileSync(${JSON.stringify(markerPath)}, "SIGTERM"); process.exit(0); });`, @@ -329,7 +329,7 @@ test( 'cli supervisor escalates stubborn supervised child before parent kill grace elapses', { skip: process.platform === 'win32' && 'Unix process-group supervisor is not used on Windows' }, async () => { - const supervisorPath = fileURLToPath(new URL('../dist/utils/cli-supervisor.js', import.meta.url)); + const supervisorPath = fileURLToPath(new URL('../dist/utils/cli/cli-supervisor.js', import.meta.url)); const childScript = ['process.on("SIGTERM", () => {});', 'setInterval(() => {}, 60_000);'].join('\n'); const supervisor = nodeSpawn(process.execPath, [supervisorPath, '--', process.execPath, '-e', childScript], { env: { @@ -858,7 +858,7 @@ test('spawnCli marks no rollout found stderr as missing_rollout reasonCode', asy }); test('formatCliExitError propagates reasonCode into message string', async () => { - const { formatCliExitError } = await import('../dist/utils/cli-format.js'); + const { formatCliExitError } = await import('../dist/utils/cli/cli-format.js'); // Without reasonCode — unchanged behavior assert.equal( @@ -1801,7 +1801,7 @@ test('F212 (云端 codex P2): maybeCollectStreamError extracts Error.message (no // serializes correctly even without the fix. The real bug surfaces only when `evt.error` is a // genuine Error instance — name/message/stack are non-enumerable on Error.prototype and would // serialize to `{}`. Test the pure helper directly to exercise the actual failure mode. - const { maybeCollectStreamError } = await import('../dist/utils/cli-spawn.js'); + const { maybeCollectStreamError } = await import('../dist/utils/cli/cli-spawn.js'); const sink = []; const evt = { type: 'error', error: new Error('401 Unauthorized') }; maybeCollectStreamError(evt, sink); @@ -1811,7 +1811,7 @@ test('F212 (云端 codex P2): maybeCollectStreamError extracts Error.message (no `Error.message must survive non-enumerable serialization; got: ${sink[0]}`, ); // Verify the classifier can actually reach the reasonCode from the extracted text - const { classifyCliError } = await import('../dist/utils/cli-diagnostics.js'); + const { classifyCliError } = await import('../dist/utils/cli/cli-diagnostics.js'); assert.equal( classifyCliError(sink[0]), 'auth_failed', @@ -1821,7 +1821,7 @@ test('F212 (云端 codex P2): maybeCollectStreamError extracts Error.message (no test('F212 (云端 codex P2): maybeCollectStreamError extracts plain object error fields', async () => { // Regression: plain {error:{name,message,data:{message,statusCode}}} should also work. - const { maybeCollectStreamError } = await import('../dist/utils/cli-spawn.js'); + const { maybeCollectStreamError } = await import('../dist/utils/cli/cli-spawn.js'); const sink = []; maybeCollectStreamError( { @@ -1836,7 +1836,7 @@ test('F212 (云端 codex P2): maybeCollectStreamError extracts plain object erro }); test('F212 (云端 codex P2): maybeCollectStreamError ignores non-error events', async () => { - const { maybeCollectStreamError } = await import('../dist/utils/cli-spawn.js'); + const { maybeCollectStreamError } = await import('../dist/utils/cli/cli-spawn.js'); const sink = []; maybeCollectStreamError({ type: 'text', content: 'hello' }, sink); maybeCollectStreamError(null, sink); @@ -1847,7 +1847,7 @@ test('F212 (云端 codex P2): maybeCollectStreamError ignores non-error events', test('F212 (云端 codex round-5 P2): maybeCollectStreamError bounds sink entries', async () => { // Long-running session emitting many error events must not grow sink unbounded. - const { maybeCollectStreamError } = await import('../dist/utils/cli-spawn.js'); + const { maybeCollectStreamError } = await import('../dist/utils/cli/cli-spawn.js'); const sink = []; // Push 100 small error events; cap is 50 entries. for (let i = 0; i < 100; i++) { @@ -1858,7 +1858,7 @@ test('F212 (云端 codex round-5 P2): maybeCollectStreamError bounds sink entrie test('F212 (云端 codex round-5 P2): maybeCollectStreamError bounds total chars', async () => { // Single huge error event must not push beyond 16384 chars total. - const { maybeCollectStreamError } = await import('../dist/utils/cli-spawn.js'); + const { maybeCollectStreamError } = await import('../dist/utils/cli/cli-spawn.js'); const sink = []; const huge = 'A'.repeat(20000); // 20KB single message maybeCollectStreamError({ type: 'error', error: { message: huge } }, sink); diff --git a/packages/api/test/cli-timeout.test.js b/packages/api/test/cli-timeout.test.js index 715a59561f..1479124d3c 100644 --- a/packages/api/test/cli-timeout.test.js +++ b/packages/api/test/cli-timeout.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; const { DEFAULT_CLI_TIMEOUT_MS, parseCliTimeoutMs, readCliTimeoutMsFromEnv, resolveCliTimeoutMs } = await import( - '../dist/utils/cli-timeout.js' + '../dist/utils/cli/cli-timeout.js' ); describe('cli-timeout', () => { diff --git a/packages/api/test/governance/skills-state.test.js b/packages/api/test/governance/skills-state.test.js index 8fc9fd3187..cf6cc40c50 100644 --- a/packages/api/test/governance/skills-state.test.js +++ b/packages/api/test/governance/skills-state.test.js @@ -17,7 +17,11 @@ import { updateSkillMountPaths, writeSkillsSyncState, } from '../../dist/skills/skill-sync-config.js'; -import { checkStaleness, computeSourceManifestHash, listSourceSkillNames } from '../../dist/utils/skill-source.js'; +import { + checkStaleness, + computeSourceManifestHash, + listSourceSkillNames, +} from '../../dist/utils/skills/skill-source.js'; let tempDir; diff --git a/packages/api/test/image-storage.test.js b/packages/api/test/image-storage.test.js index b382dfd709..46983e3554 100644 --- a/packages/api/test/image-storage.test.js +++ b/packages/api/test/image-storage.test.js @@ -19,7 +19,7 @@ describe('image-storage', () => { }); it('saves a validated image buffer to uploadDir and returns /uploads metadata', async () => { - const { saveImageBufferToUploadDir } = await import('../dist/utils/image-storage.js'); + const { saveImageBufferToUploadDir } = await import('../dist/utils/media/image-storage.js'); const result = await saveImageBufferToUploadDir({ buffer: Buffer.from('fake-png'), @@ -38,7 +38,7 @@ describe('image-storage', () => { }); it('copies a local image file into uploadDir and returns canonical /uploads metadata', async () => { - const { copyImageFileToUploadDir } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir } = await import('../dist/utils/media/image-storage.js'); const sourcePath = join(sourceDir, 'source.png'); await writeFile(sourcePath, Buffer.from('source-png')); @@ -59,7 +59,7 @@ describe('image-storage', () => { }); it('rejects copied image files exceeding 10MB', async () => { - const { copyImageFileToUploadDir, ImageUploadError } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir, ImageUploadError } = await import('../dist/utils/media/image-storage.js'); const sourcePath = join(sourceDir, 'too-large.png'); await writeFile(sourcePath, Buffer.alloc(10 * 1024 * 1024 + 1)); @@ -78,7 +78,7 @@ describe('image-storage', () => { it('throws on duplicate target when onExists is error', async () => { const { copyImageFileToUploadDir, saveImageBufferToUploadDir, ImageUploadError } = await import( - '../dist/utils/image-storage.js' + '../dist/utils/media/image-storage.js' ); const sourcePath = join(sourceDir, 'source.png'); @@ -125,7 +125,7 @@ describe('image-storage', () => { }); it('reuses duplicate target when onExists is reuse', async () => { - const { copyImageFileToUploadDir } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir } = await import('../dist/utils/media/image-storage.js'); const sourcePath = join(sourceDir, 'source.png'); await writeFile(sourcePath, Buffer.from('source-png')); @@ -151,7 +151,7 @@ describe('image-storage', () => { }); it('honors onExists when source path already equals the target path', async () => { - const { copyImageFileToUploadDir, ImageUploadError } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir, ImageUploadError } = await import('../dist/utils/media/image-storage.js'); const sourcePath = join(uploadDir, 'same-path.png'); await writeFile(sourcePath, Buffer.from('already-published')); @@ -181,7 +181,7 @@ describe('image-storage', () => { }); it('rejects missing same-path source instead of returning phantom metadata', async () => { - const { copyImageFileToUploadDir } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir } = await import('../dist/utils/media/image-storage.js'); const missingSourcePath = join(uploadDir, 'missing-same-path.png'); @@ -199,7 +199,7 @@ describe('image-storage', () => { }); it('reuses an existing target when original source has been cleaned up', async () => { - const { copyImageFileToUploadDir } = await import('../dist/utils/image-storage.js'); + const { copyImageFileToUploadDir } = await import('../dist/utils/media/image-storage.js'); const existingTargetPath = join(uploadDir, 'recovered-image.png'); const missingSourcePath = join(sourceDir, 'already-cleaned-up.png'); diff --git a/packages/api/test/image-upload.test.js b/packages/api/test/image-upload.test.js index 87e5758678..67f044b42b 100644 --- a/packages/api/test/image-upload.test.js +++ b/packages/api/test/image-upload.test.js @@ -113,7 +113,7 @@ describe('saveUploadedImages', () => { describe('extractImagePaths', () => { it('uses packages/api/uploads as the default upload dir regardless of cwd', async () => { - const { getDefaultUploadDir } = await import('../dist/utils/upload-paths.js'); + const { getDefaultUploadDir } = await import('../dist/utils/media/upload-paths.js'); const dir = getDefaultUploadDir(); assert.ok( diff --git a/packages/api/test/integration/thread-wiring.test.js b/packages/api/test/integration/thread-wiring.test.js index 6d567a5ded..90c8e80378 100644 --- a/packages/api/test/integration/thread-wiring.test.js +++ b/packages/api/test/integration/thread-wiring.test.js @@ -25,7 +25,7 @@ const { MessageStore } = await import('../../dist/domains/cats/services/stores/p const { ThreadStore } = await import('../../dist/domains/cats/services/stores/ports/ThreadStore.js'); const { threadsRoutes } = await import('../../dist/routes/threads.js'); const { messagesRoutes } = await import('../../dist/routes/messages.js'); -const { findMonorepoRoot } = await import('../../dist/utils/monorepo-root.js'); +const { findMonorepoRoot } = await import('../../dist/utils/paths/monorepo-root.js'); // --- Helpers --- diff --git a/packages/api/test/jsonl-tail-reader.test.js b/packages/api/test/jsonl-tail-reader.test.js index af355081fc..d7e87f8bf4 100644 --- a/packages/api/test/jsonl-tail-reader.test.js +++ b/packages/api/test/jsonl-tail-reader.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -const { readJsonlTail } = await import('../dist/utils/jsonl-tail-reader.js'); +const { readJsonlTail } = await import('../dist/utils/parsing/jsonl-tail-reader.js'); function makeJsonlFile(entries) { const dir = mkdtempSync(join(tmpdir(), 'jsonl-tail-')); diff --git a/packages/api/test/kimi-agent-service.test.js b/packages/api/test/kimi-agent-service.test.js index 4246c096aa..b5fa598d13 100644 --- a/packages/api/test/kimi-agent-service.test.js +++ b/packages/api/test/kimi-agent-service.test.js @@ -14,7 +14,7 @@ writeFileSync(join(stubBinDir, 'kimi-cli'), '#!/bin/sh\nexit 1\n', { mode: 0o755 process.env.PATH = `${stubBinDir}:${process.env.PATH}`; const { KimiAgentService } = await import('../dist/domains/cats/services/agents/providers/KimiAgentService.js'); -const { invalidateCliCommand } = await import('../dist/utils/cli-resolve.js'); +const { invalidateCliCommand } = await import('../dist/utils/cli/cli-resolve.js'); async function collect(iterable) { const items = []; diff --git a/packages/api/test/local-override.test.js b/packages/api/test/local-override.test.js index 5d8a7a2231..5f7449ea8a 100644 --- a/packages/api/test/local-override.test.js +++ b/packages/api/test/local-override.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -const { resolveWithLocalOverlay } = await import('../dist/utils/local-override.js'); +const { resolveWithLocalOverlay } = await import('../dist/utils/paths/local-override.js'); const TMP = join(tmpdir(), `local-override-test-${Date.now()}`); diff --git a/packages/api/test/memory-root.test.js b/packages/api/test/memory-root.test.js index ee4dff91b8..423a54f0c9 100644 --- a/packages/api/test/memory-root.test.js +++ b/packages/api/test/memory-root.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -const { resolveMemoryRepoPaths } = await import('../dist/utils/memory-root.js'); +const { resolveMemoryRepoPaths } = await import('../dist/utils/paths/memory-root.js'); describe('resolveMemoryRepoPaths', () => { const tmpDirs = []; diff --git a/packages/api/test/monorepo-root.test.js b/packages/api/test/monorepo-root.test.js index 10f036f4a6..7640c74f3a 100644 --- a/packages/api/test/monorepo-root.test.js +++ b/packages/api/test/monorepo-root.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -const { _clearCachesForTest, findMonorepoRoot } = await import('../dist/utils/monorepo-root.js'); +const { _clearCachesForTest, findMonorepoRoot } = await import('../dist/utils/paths/monorepo-root.js'); describe('findMonorepoRoot', () => { afterEach(() => { diff --git a/packages/api/test/mount-rules-route.test.js b/packages/api/test/mount-rules-route.test.js index 7192fb64bd..c70ad473e4 100644 --- a/packages/api/test/mount-rules-route.test.js +++ b/packages/api/test/mount-rules-route.test.js @@ -16,7 +16,7 @@ import { mountRulesRoutes } from '../dist/routes/mount-rules.js'; import { mountSkillSymlinks } from '../dist/skills/skill-manage.js'; import { syncAll } from '../dist/skills/skill-sync-all.js'; import { syncProject } from '../dist/skills/skill-sync-engine.js'; -import { resolveCatCafeSkillsSource } from '../dist/utils/skill-source.js'; +import { resolveCatCafeSkillsSource } from '../dist/utils/skills/skill-source.js'; function resolveRepoRoot() { return execFileSync('git', ['rev-parse', '--show-toplevel'], { diff --git a/packages/api/test/ndjson-parser.test.js b/packages/api/test/ndjson-parser.test.js index 505f851361..2bd466b4d1 100644 --- a/packages/api/test/ndjson-parser.test.js +++ b/packages/api/test/ndjson-parser.test.js @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { PassThrough } from 'node:stream'; import { test } from 'node:test'; -const { parseNDJSON, isParseError } = await import('../dist/utils/ndjson-parser.js'); +const { parseNDJSON, isParseError } = await import('../dist/utils/parsing/ndjson-parser.js'); /** Helper: collect all items from async iterable */ async function collect(iterable) { diff --git a/packages/api/test/normalize-error.test.js b/packages/api/test/normalize-error.test.js index 5853fa55f5..457d2cb86a 100644 --- a/packages/api/test/normalize-error.test.js +++ b/packages/api/test/normalize-error.test.js @@ -6,7 +6,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -const { normalizeErrorMessage } = await import('../dist/utils/normalize-error.js'); +const { normalizeErrorMessage } = await import('../dist/utils/parsing/normalize-error.js'); test('Error instance → .message', () => { assert.equal(normalizeErrorMessage(new Error('boom')), 'boom'); diff --git a/packages/api/test/orphan-chrome-cleaner.test.js b/packages/api/test/orphan-chrome-cleaner.test.js index a9d3c7218e..0846e6c5fe 100644 --- a/packages/api/test/orphan-chrome-cleaner.test.js +++ b/packages/api/test/orphan-chrome-cleaner.test.js @@ -3,7 +3,7 @@ import { describe, test } from 'node:test'; await import('tsx/esm'); const { cleanOrphanAgentBrowserChrome, parseAgentBrowserChromeCleanupPids, parseOrphanPids } = await import( - '../src/utils/orphan-chrome-cleaner.ts' + '../src/utils/process/orphan-chrome-cleaner.ts' ); const fakeLog = { diff --git a/packages/api/test/plugin-agent-provider-activate.test.js b/packages/api/test/plugin-agent-provider-activate.test.js index e67fa92e3d..5fcf68aaff 100644 --- a/packages/api/test/plugin-agent-provider-activate.test.js +++ b/packages/api/test/plugin-agent-provider-activate.test.js @@ -65,6 +65,24 @@ function makeActivator({ providerTransportRegistry = { has: (transportId) => tra return { activator, capStore }; } +function makeActivatorWithStore({ + readCapabilities, + writeCapabilities, + withCapabilityLock, + providerTransportRegistry = { has: (transportId) => transportId === 'cli-jsonl' }, +}) { + const activator = new PluginResourceActivator({ + resolveProjectRoot: () => '/tmp/project', + pluginsDir: '/tmp/project/plugins', + limbRegistry: { register: async () => {}, deregister: () => {} }, + readCapabilities, + writeCapabilities, + withCapabilityLock, + providerTransportRegistry, + }); + return { activator }; +} + describe('PluginResourceActivator - agentProvider resources', () => { it('activates agentProvider as transportReady but not routeable', async () => { const { activator, capStore } = makeActivator(); @@ -143,6 +161,50 @@ describe('PluginResourceActivator - agentProvider descriptor hash (Slice 2b)', ( assert.equal(entry.agentProvider.health.passed, true); }); + it('re-reads the existing descriptor under the capability lock before preserving approval state', async () => { + const { activator: seedActivator, capStore } = makeActivator(); + await seedActivator.enablePlugin(makeManifest()); + + const beforeApproval = structuredClone(capStore.get()); + const afterApproval = structuredClone(beforeApproval); + afterApproval.capabilities[0].agentProvider.routeableApproved = true; + afterApproval.capabilities[0].agentProvider.routeable = true; + afterApproval.capabilities[0].agentProvider.state = 'healthy'; + afterApproval.capabilities[0].agentProvider.health = { + passed: true, + checkedAt: 1000, + ttlMs: 60000, + descriptorHash: beforeApproval.capabilities[0].agentProvider.descriptorHash, + }; + + let insideLock = false; + let written = null; + const { activator } = makeActivatorWithStore({ + readCapabilities: async () => structuredClone(insideLock ? afterApproval : beforeApproval), + writeCapabilities: async (next) => { + written = structuredClone(next); + }, + withCapabilityLock: async (fn) => { + insideLock = true; + try { + return await fn(); + } finally { + insideLock = false; + } + }, + }); + + await activator.enablePlugin(makeManifest()); + + const entry = written?.capabilities[0]; + assert.ok(entry); + assert.equal(entry.agentProvider.routeableApproved, true, 'approval should preserve the lock-visible state'); + assert.equal(entry.agentProvider.routeable, true, 'routeable should preserve the lock-visible state'); + assert.equal(entry.agentProvider.state, 'healthy', 'state should preserve the lock-visible state'); + assert.ok(entry.agentProvider.health, 'health should preserve the lock-visible state'); + assert.equal(entry.agentProvider.health.passed, true); + }); + it('resets routeableApproved + invalidates health when descriptor changes', async () => { const { activator, capStore } = makeActivator(); await activator.enablePlugin(makeManifest()); diff --git a/packages/api/test/process-liveness-probe.test.js b/packages/api/test/process-liveness-probe.test.js index 132d07dbe2..3921c87931 100644 --- a/packages/api/test/process-liveness-probe.test.js +++ b/packages/api/test/process-liveness-probe.test.js @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -const { ProcessLivenessProbe } = await import('../dist/utils/ProcessLivenessProbe.js'); +const { ProcessLivenessProbe } = await import('../dist/utils/process/ProcessLivenessProbe.js'); async function waitForBusySilent(probe, { timeoutMs = 3_000, burnMs = 180, settleMs = 40 } = {}) { const deadline = Date.now() + timeoutMs; @@ -196,7 +196,7 @@ test( }, ); -const { parseCpuTime } = await import('../dist/utils/ProcessLivenessProbe.js'); +const { parseCpuTime } = await import('../dist/utils/process/ProcessLivenessProbe.js'); test('parseCpuTime handles mm:ss.SS format', () => { assert.equal(parseCpuTime('1:30.50'), (1 * 60 + 30.5) * 1000); diff --git a/packages/api/test/project-path.test.js b/packages/api/test/project-path.test.js index cc0a4e9b79..e07fb25932 100644 --- a/packages/api/test/project-path.test.js +++ b/packages/api/test/project-path.test.js @@ -12,7 +12,7 @@ const { getDefaultDeniedRoots, isPathUnderRoots, isDenylistMode, -} = await import('../dist/utils/project-path.js'); +} = await import('../dist/utils/paths/project-path.js'); describe('denylist mode (default)', () => { let savedAllowedRoots; diff --git a/packages/api/test/public-test-exclusions.test.js b/packages/api/test/public-test-exclusions.test.js index 6befaac796..3c4ce6ea8f 100644 --- a/packages/api/test/public-test-exclusions.test.js +++ b/packages/api/test/public-test-exclusions.test.js @@ -99,7 +99,7 @@ test('registry preserves metadata for active legacy exclusions and drops stale o category: 'source_only', owner: '@zts212653', introducedBy: '069d0f0fb', - expiresOn: '2026-06-30', + expiresOn: '2026-07-31', }, ); }); @@ -133,7 +133,7 @@ test('validator rejects malformed, expired, or zero-match exclusion entries', as category: 'source_only', reason: 'missing owner should fail', introducedBy: 'deadbeef0', - expiresOn: '2026-06-30', + expiresOn: '2026-07-31', }, ], }, @@ -177,7 +177,7 @@ test('validator rejects malformed, expired, or zero-match exclusion entries', as reason: 'stale entry should fail', owner: '@zts212653', introducedBy: 'deadbeef2', - expiresOn: '2026-06-30', + expiresOn: '2026-07-31', }, ], }, diff --git a/packages/api/test/routing-admission-service.test.js b/packages/api/test/routing-admission-service.test.js index b81af1df13..adf3aad0d6 100644 --- a/packages/api/test/routing-admission-service.test.js +++ b/packages/api/test/routing-admission-service.test.js @@ -108,6 +108,17 @@ describe('RoutingAdmissionService — admitForRouting', () => { assert.equal(result.conflictingIdentity, 'anthropic'); }); + it('normalizes routeable identities before reserved-baseline checks', () => { + const result = admitForRouting( + makeCandidate({ providerId: 'OpenAI' }), + makeSnapshot({ + templateBaselineIds: new Set(['openai']), + }), + ); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'openai'); + }); + it('denies when catId collides with the cat-template baseline', () => { const result = admitForRouting(makeCandidate({ providerId: 'clowder-code', catId: 'openai' }), makeSnapshot()); assert.equal(result.reason, 'reserved-baseline-collision'); @@ -143,6 +154,17 @@ describe('RoutingAdmissionService — admitForRouting', () => { assert.equal(result.reason, 'existing-routeable-collision'); assert.equal(result.conflictingIdentity, 'plugin-b'); }); + + it('normalizes @-aliases before existing-routeable collision checks', () => { + const result = admitForRouting( + makeCandidate({ mentionPatterns: ['@codex'] }), + makeSnapshot({ + existingRouteableIdentities: new Set(['@Codex']), + }), + ); + assert.equal(result.reason, 'existing-routeable-collision'); + assert.equal(result.conflictingIdentity, 'codex'); + }); }); describe('active-cat collision', () => { diff --git a/packages/api/test/sanitize-cli-stderr.test.js b/packages/api/test/sanitize-cli-stderr.test.js index 7c96d85064..612fd0d9f1 100644 --- a/packages/api/test/sanitize-cli-stderr.test.js +++ b/packages/api/test/sanitize-cli-stderr.test.js @@ -5,7 +5,7 @@ import assert from 'node:assert'; import test from 'node:test'; -import { sanitizeCliStderr } from '../dist/utils/sanitize-cli-stderr.js'; +import { sanitizeCliStderr } from '../dist/utils/cli/sanitize-cli-stderr.js'; test('strips ANSI escape sequences', () => { const input = '\x1b[31mError\x1b[0m: thing'; diff --git a/packages/api/test/skill-mount.test.js b/packages/api/test/skill-mount.test.js index eef0fa1851..bc35a4ffa6 100644 --- a/packages/api/test/skill-mount.test.js +++ b/packages/api/test/skill-mount.test.js @@ -9,7 +9,7 @@ const REPO_ROOT = resolve(API_DIR, '../..'); describe('resolveMainRepoPath', () => { it('falls back to the repository root when git is unavailable', () => { const script = ` -const mod = await import('./dist/utils/skill-mount.js'); +const mod = await import('./dist/utils/skills/skill-mount.js'); console.log(await mod.resolveMainRepoPath()); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { diff --git a/packages/api/test/skills/drift-detector.test.js b/packages/api/test/skills/drift-detector.test.js index 93904b0f9b..19f18999c1 100644 --- a/packages/api/test/skills/drift-detector.test.js +++ b/packages/api/test/skills/drift-detector.test.js @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, test } from 'node:test'; import { DEFAULT_MOUNT_RULES } from '@cat-cafe/shared'; import { checkGlobal, checkProject } from '../../dist/skills/drift-detector.js'; -import { listSourceSkillNames } from '../../dist/utils/skill-source.js'; +import { listSourceSkillNames } from '../../dist/utils/skills/skill-source.js'; /** * Test helper: wraps checkGlobal for mount-level drift testing. diff --git a/packages/api/test/skills/drift-resolver.test.js b/packages/api/test/skills/drift-resolver.test.js index 9e10ad5923..c715a46f3d 100644 --- a/packages/api/test/skills/drift-resolver.test.js +++ b/packages/api/test/skills/drift-resolver.test.js @@ -13,7 +13,7 @@ import { import { checkGlobal } from '../../dist/skills/drift-detector.js'; import { syncDrift } from '../../dist/skills/drift-resolver.js'; import { syncProject } from '../../dist/skills/skill-sync-engine.js'; -import { checkStaleness, listSourceSkillNames } from '../../dist/utils/skill-source.js'; +import { checkStaleness, listSourceSkillNames } from '../../dist/utils/skills/skill-source.js'; /** * Test helper: wraps checkGlobal for mount-level drift detection. diff --git a/packages/api/test/tcp-probe.test.js b/packages/api/test/tcp-probe.test.js index d8a0d30730..5dc5683d12 100644 --- a/packages/api/test/tcp-probe.test.js +++ b/packages/api/test/tcp-probe.test.js @@ -6,7 +6,7 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:net'; import { describe, it } from 'node:test'; -const { tcpProbe } = await import('../dist/utils/tcp-probe.js'); +const { tcpProbe } = await import('../dist/utils/network/tcp-probe.js'); describe('tcpProbe', () => { it('returns true for a listening port', async () => { diff --git a/packages/api/test/telemetry/cli-spawn-redaction.test.js b/packages/api/test/telemetry/cli-spawn-redaction.test.js index 1059d49bac..cf26bada30 100644 --- a/packages/api/test/telemetry/cli-spawn-redaction.test.js +++ b/packages/api/test/telemetry/cli-spawn-redaction.test.js @@ -16,7 +16,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_SPAWN_SRC = resolve(__dirname, '../../src/utils/cli-spawn.ts'); +const CLI_SPAWN_SRC = resolve(__dirname, '../../src/utils/cli/cli-spawn.ts'); test('F152: cli-spawn Windows shim debug log must not contain args field', async (t) => { // Read the source file and find the Windows shim debug log line diff --git a/packages/api/test/telemetry/observability-coverage.test.js b/packages/api/test/telemetry/observability-coverage.test.js index e2922da9a0..4b618206c4 100644 --- a/packages/api/test/telemetry/observability-coverage.test.js +++ b/packages/api/test/telemetry/observability-coverage.test.js @@ -39,7 +39,7 @@ test('F153: liveness probe register/unregister lifecycle', async (t) => { }); test('F153: cli-spawn wires liveness probes', () => { - const source = readFileSync(resolve(__dirname, '../../src/utils/cli-spawn.ts'), 'utf8'); + const source = readFileSync(resolve(__dirname, '../../src/utils/cli/cli-spawn.ts'), 'utf8'); // Must import both register and unregister assert.ok(source.includes('registerLivenessProbe'), 'cli-spawn must import registerLivenessProbe'); diff --git a/packages/api/test/telemetry/otel-tracing-phase-b.test.js b/packages/api/test/telemetry/otel-tracing-phase-b.test.js index 33bf4b9b2c..4e8d6a0973 100644 --- a/packages/api/test/telemetry/otel-tracing-phase-b.test.js +++ b/packages/api/test/telemetry/otel-tracing-phase-b.test.js @@ -17,8 +17,8 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_SPAWN_SRC = resolve(__dirname, '../../src/utils/cli-spawn.ts'); -const CLI_TYPES_SRC = resolve(__dirname, '../../src/utils/cli-types.ts'); +const CLI_SPAWN_SRC = resolve(__dirname, '../../src/utils/cli/cli-spawn.ts'); +const CLI_TYPES_SRC = resolve(__dirname, '../../src/utils/cli/cli-types.ts'); const TYPES_SRC = resolve(__dirname, '../../src/domains/cats/services/types.ts'); const CLAUDE_SERVICE_SRC = resolve(__dirname, '../../src/domains/cats/services/agents/providers/ClaudeAgentService.ts'); const INVOKE_SRC = resolve(__dirname, '../../src/domains/cats/services/agents/invocation/invoke-single-cat.ts'); diff --git a/packages/api/test/telemetry/otel-tracing-runtime.test.js b/packages/api/test/telemetry/otel-tracing-runtime.test.js index dda1bad26a..a485b2f034 100644 --- a/packages/api/test/telemetry/otel-tracing-runtime.test.js +++ b/packages/api/test/telemetry/otel-tracing-runtime.test.js @@ -20,7 +20,7 @@ const { InMemorySpanExporter, SimpleSpanProcessor } = await import('@opentelemet const { NodeTracerProvider } = await import('@opentelemetry/sdk-trace-node'); // Module under test -const { spawnCli } = await import('../../dist/utils/cli-spawn.js'); +const { spawnCli } = await import('../../dist/utils/cli/cli-spawn.js'); /** Collect all items from an async iterable */ async function collect(iterable) { diff --git a/packages/api/test/utils/is-same-repo.test.js b/packages/api/test/utils/is-same-repo.test.js index 5483a7eb98..187a2ffc9f 100644 --- a/packages/api/test/utils/is-same-repo.test.js +++ b/packages/api/test/utils/is-same-repo.test.js @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, before, describe, it } from 'node:test'; -import { initRepoIdentity, isSameRepo } from '../../dist/utils/is-same-repo.js'; +import { initRepoIdentity, isSameRepo } from '../../dist/utils/paths/is-same-repo.js'; function configureTestRepo(repoPath) { execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repoPath }); diff --git a/packages/api/test/utils/skill-mount-targets.test.js b/packages/api/test/utils/skill-mount-targets.test.js index 4c16ae4b03..e621f8be43 100644 --- a/packages/api/test/utils/skill-mount-targets.test.js +++ b/packages/api/test/utils/skill-mount-targets.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { DEFAULT_MOUNT_RULES } from '@cat-cafe/shared'; -import { buildMountPointDirCandidates, buildSkillMountTargets } from '../../dist/utils/skill-mount.js'; +import { buildMountPointDirCandidates, buildSkillMountTargets } from '../../dist/utils/skills/skill-mount.js'; const PROJECT = '/tmp/proj'; const HOME = '/Users/test'; diff --git a/packages/api/test/utils/skill-source.test.js b/packages/api/test/utils/skill-source.test.js index 2a90daf289..ddc271edb5 100644 --- a/packages/api/test/utils/skill-source.test.js +++ b/packages/api/test/utils/skill-source.test.js @@ -4,7 +4,7 @@ import { dirname, join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { resolveCatCafeSkillsSource } from '../../dist/utils/skill-source.js'; +import { resolveCatCafeSkillsSource } from '../../dist/utils/skills/skill-source.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '../../../..'); @@ -15,7 +15,7 @@ describe('Cat Cafe skills source resolver', () => { }); test('does not route lifecycle skill source through resolveMainRepoPath', async () => { - const resolverSource = await readFile(join(repoRoot, 'packages/api/src/utils/skill-source.ts'), 'utf-8'); + const resolverSource = await readFile(join(repoRoot, 'packages/api/src/utils/skills/skill-source.ts'), 'utf-8'); assert.doesNotMatch(resolverSource, /resolveMainRepoPath/); }); diff --git a/packages/web/src/components/ChatContainer.tsx b/packages/web/src/components/ChatContainer.tsx index bc135296f5..e6c3fbdd04 100644 --- a/packages/web/src/components/ChatContainer.tsx +++ b/packages/web/src/components/ChatContainer.tsx @@ -642,6 +642,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) { CatData | undefined; onEditCat?: (catId: string) => void; /** F056 follow-up: click co-creator avatar to open editor (consistent with cat avatar behavior). */ @@ -90,6 +96,7 @@ interface ChatMessageProps { export function ChatMessage({ message, + threadId, getCatById, onEditCat, onEditCoCreator, @@ -103,6 +110,17 @@ export function ChatMessage({ const threads = useChatStore((s) => s.threads); const threadMessages = useChatStore((s) => s.messages); const globalBubbleDefaults = useChatStore((s) => s.globalBubbleDefaults); + // F070 self-healing handler: drop a stale governance_blocked banner from its owning + // thread when the card's mount-time health probe confirms governance is healthy. + // 砚砚 R2 P1: must target the *rendering* thread (split-pane / background thread may + // not equal currentThreadId). Fall back to currentThreadId only when threadId prop + // is absent (legacy callers). + const removeThreadMessage = useChatStore((s) => s.removeThreadMessage); + const owningThreadId = threadId ?? currentThreadId; + const handleGovernanceBannerSelfClear = useCallback(() => { + if (!owningThreadId) return; + removeThreadMessage(owningThreadId, message.id); + }, [owningThreadId, message.id, removeThreadMessage]); const isUser = message.type === 'user' && !message.catId; const isSystem = message.type === 'system'; const isSummary = message.type === 'summary'; @@ -237,8 +255,19 @@ export function ChatMessage({ } if (message.variant === 'governance_blocked' && message.extra?.governanceBlocked) { - const { projectPath, reasonKind, invocationId } = message.extra.governanceBlocked; - return ; + const { projectPath, reasonKind, invocationId, clientId } = message.extra.governanceBlocked; + // F070 self-healing: banner is a transient store/IDB message — if governance has + // been initialized since (e.g. user confirmed elsewhere or earlier session), let + // the card clear itself on mount so a stale banner doesn't linger forever. + return ( + + ); } // F045: variant='thinking' is deprecated — thinking is now embedded in assistant bubbles. diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index 0e43766cac..1a609eed99 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -6,6 +6,15 @@ interface GovernanceBlockedCardProps { projectPath: string; reasonKind: 'needs_bootstrap' | 'needs_confirmation' | 'files_missing'; invocationId?: string; + /** Provider clientId from the blocked dispatch; keeps self-clear aligned with backend preflight. */ + clientId?: string; + /** + * F070 self-healing (#TODO-followup): called when this banner detects that governance + * is already healthy on the server. Parent should remove the underlying transient + * message so a stale banner from a past failed invocation doesn't linger after the + * user has already initialized governance (in another thread / from CLI / earlier session). + */ + onSelfClear?: () => void; } const REASON_LABELS: Record = { @@ -16,7 +25,19 @@ const REASON_LABELS: Record = { type CardState = 'idle' | 'confirming' | 'retrying' | 'done' | 'error'; -export function GovernanceBlockedCard({ projectPath, reasonKind, invocationId }: GovernanceBlockedCardProps) { +interface GovernanceStatusResponse { + ready?: boolean; +} + +const LEGACY_GOVERNANCE_SELF_HEAL_CLIENT_IDS = ['anthropic', 'openai', 'google', 'kimi'] as const; + +export function GovernanceBlockedCard({ + projectPath, + reasonKind, + invocationId, + clientId, + onSelfClear, +}: GovernanceBlockedCardProps) { const [state, setState] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -29,6 +50,39 @@ export function GovernanceBlockedCard({ projectPath, reasonKind, invocationId }: } }, [invocationId]); + // F070 self-healing: if the server already considers this project healthy, the banner + // is stale (left over in store/IDB from a past failed invocation) — ask the parent to + // remove it. We only fire while the user has not interacted (state === 'idle'); once + // they've started bootstrap, the in-flight state machine owns the lifecycle. + useEffect(() => { + if (!onSelfClear || state !== 'idle') return; + const scopedClientId = clientId?.trim(); + const clientIdsToProbe = scopedClientId ? [scopedClientId] : LEGACY_GOVERNANCE_SELF_HEAL_CLIENT_IDS; + let canceled = false; + (async () => { + try { + const readyByScope = await Promise.all( + clientIdsToProbe.map(async (probeClientId) => { + const params = new URLSearchParams({ projectPath }); + params.set('clientId', probeClientId); + const res = await apiFetch(`/api/governance/status?${params.toString()}`); + if (!res.ok) return false; + const data = (await res.json()) as GovernanceStatusResponse; + return data.ready === true; + }), + ); + if (!canceled && readyByScope.every(Boolean)) { + onSelfClear(); + } + } catch { + // network failure — leave banner visible; user can still bootstrap manually + } + })(); + return () => { + canceled = true; + }; + }, [projectPath, clientId, onSelfClear, state]); + const handleBootstrap = useCallback(async () => { setState('confirming'); setErrorMsg(''); diff --git a/packages/web/src/components/HubCatEditor.tsx b/packages/web/src/components/HubCatEditor.tsx index 220bb9a0fd..3da1519725 100644 --- a/packages/web/src/components/HubCatEditor.tsx +++ b/packages/web/src/components/HubCatEditor.tsx @@ -343,12 +343,8 @@ export function HubCatEditor({ cat, draft, existingCats, open, onClose, onSaved ? { clientId: t.runtimeDefaults.clientId, defaultModel: t.runtimeDefaults.defaultModel, - ...(t.runtimeDefaults.catAgentProtocol - ? { catAgentProtocol: t.runtimeDefaults.catAgentProtocol } - : {}), - ...(t.runtimeDefaults.nativeToolLevel - ? { nativeToolLevel: t.runtimeDefaults.nativeToolLevel } - : {}), + ...(t.runtimeDefaults.catAgentProtocol ? { catAgentProtocol: t.runtimeDefaults.catAgentProtocol } : {}), + ...(t.runtimeDefaults.nativeToolLevel ? { nativeToolLevel: t.runtimeDefaults.nativeToolLevel } : {}), } : {}), }); diff --git a/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx b/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx index 14925164d5..86dd8aff22 100644 --- a/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx +++ b/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx @@ -336,9 +336,7 @@ describe('FirstRunQuestWizard', () => { await flushEffects(); // Step 1: select 幼仔 template - const templateButton = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.includes('幼猫'), - ); + const templateButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('幼猫')); expect(templateButton).toBeTruthy(); await act(async () => { templateButton!.click(); diff --git a/packages/web/src/components/__tests__/governance-blocked-card.test.ts b/packages/web/src/components/__tests__/governance-blocked-card.test.ts index a8af3b637b..18d3cacefa 100644 --- a/packages/web/src/components/__tests__/governance-blocked-card.test.ts +++ b/packages/web/src/components/__tests__/governance-blocked-card.test.ts @@ -167,6 +167,268 @@ describe('GovernanceBlockedCard', () => { expect(container.textContent).not.toContain('C:\\workspace\\tmp'); }); + it('calls onSelfClear when provider-scoped governance status is ready on mount (F070 stale-banner self-heal)', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ready: true, + needsBootstrap: false, + needsConfirmation: false, + }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-stale', + clientId: 'openai', + onSelfClear, + }), + ); + // flush mount effect microtasks + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai'); + expect(onSelfClear).toHaveBeenCalledTimes(1); + }); + + it('self-clears legacy unscoped banners only when all governance provider scopes are ready', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + ready: true, + needsBootstrap: false, + needsConfirmation: false, + }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-legacy', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch.mock.calls.map(([url]) => url)).toEqual([ + '/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=anthropic', + '/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai', + '/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=google', + '/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=kimi', + ]); + expect(onSelfClear).toHaveBeenCalledTimes(1); + }); + + it('keeps legacy unscoped banners visible when any provider scope is still blocked', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockImplementation(async (url: string) => ({ + ok: true, + json: async () => ({ + ready: !url.includes('clientId=openai'), + needsBootstrap: url.includes('clientId=openai'), + needsConfirmation: false, + }), + })); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-legacy', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledTimes(4); + expect(onSelfClear).not.toHaveBeenCalled(); + }); + + it('scopes self-heal status probe to the blocked cat provider', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockImplementation(async (url: string) => { + if (url === '/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai') { + return { + ok: true, + json: async () => ({ + ready: false, + needsBootstrap: true, + needsConfirmation: false, + }), + }; + } + if (url === '/api/governance/status?projectPath=%2Ftest%2Fproj') { + return { + ok: true, + json: async () => ({ + ready: true, + needsBootstrap: false, + needsConfirmation: false, + }), + }; + } + throw new Error(`unexpected url: ${url}`); + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-stale', + clientId: 'openai', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai'); + expect(onSelfClear).not.toHaveBeenCalled(); + }); + + it('does not call onSelfClear when governance status is not ready', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ready: false, + needsBootstrap: true, + needsConfirmation: false, + }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + clientId: 'openai', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSelfClear).not.toHaveBeenCalled(); + // banner stays visible — bootstrap button still rendered + expect(container.querySelector('button')?.textContent).toContain('初始化治理并继续'); + }); + + it('does not self-clear from registry-only health when project preflight is still blocked', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockImplementation(async (url: string) => { + if (url === '/api/governance/health') { + return { + ok: true, + json: async () => ({ + projects: [{ projectPath: '/test/proj', status: 'healthy' }], + }), + }; + } + if (url === '/api/governance/status?projectPath=%2Ftest%2Fproj') { + return { + ok: true, + json: async () => ({ + ready: false, + needsBootstrap: false, + needsConfirmation: true, + }), + }; + } + throw new Error(`unexpected url: ${url}`); + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_confirmation', + clientId: 'openai', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai'); + expect(onSelfClear).not.toHaveBeenCalled(); + }); + + it('does not call onSelfClear when server omits the project', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ projects: [] }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + clientId: 'openai', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSelfClear).not.toHaveBeenCalled(); + }); + + it('does not call onSelfClear when health endpoint fails (network error)', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockRejectedValueOnce(new Error('network down')); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + clientId: 'openai', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSelfClear).not.toHaveBeenCalled(); + expect(container.querySelector('button')?.textContent).toContain('初始化治理并继续'); + }); + + it('skips self-heal probe when onSelfClear is not provided', async () => { + act(() => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + }), + ); + }); + + expect(mockApiFetch).not.toHaveBeenCalled(); + }); + it('resets to idle state when invocationId prop changes', async () => { mockApiFetch .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) diff --git a/packages/web/src/components/first-run-quest/TemplateStep.tsx b/packages/web/src/components/first-run-quest/TemplateStep.tsx index ed07f375d0..aa3b9928d5 100644 --- a/packages/web/src/components/first-run-quest/TemplateStep.tsx +++ b/packages/web/src/components/first-run-quest/TemplateStep.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; import type { CatAgentProtocol, NativeToolLevel } from '@cat-cafe/shared'; +import { useEffect, useState } from 'react'; import { apiFetch } from '@/utils/api-client'; import type { ClientId } from '../hub-cat-editor.model'; diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts index 7d597a8360..836ef250f0 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts @@ -772,6 +772,157 @@ describe('background thread socket handling', () => { }); }); + describe('F070 governance_blocked background refresh', () => { + it('replaces same-project stale banner by appending the fresh banner at transcript end and bumping unread', () => { + const now = Date.now(); + useChatStore.getState().replaceThreadMessages( + 'thread-bg-gov', + [ + { + id: 'old-banner', + type: 'system', + variant: 'governance_blocked', + content: 'old governance banner', + timestamp: now, + extra: { + governanceBlocked: { + projectPath: '/work/project', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-old', + }, + }, + }, + { + id: 'later-user-prompt', + type: 'user', + content: 'latest prompt that should stay before refreshed banner', + timestamp: now + 1, + }, + ], + true, + ); + const previousActivity = now - 10_000; + const seededThreadState = useChatStore.getState().getThreadState('thread-bg-gov'); + useChatStore.setState((state) => ({ + threadStates: { + ...state.threadStates, + 'thread-bg-gov': { + ...seededThreadState, + lastActivity: previousActivity, + }, + }, + })); + + simulateBackgroundMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-bg-gov', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'needs_confirmation', + invocationId: 'inv-new', + clientId: 'openai', + }), + timestamp: now + 2, + }); + + const ts = useChatStore.getState().getThreadState('thread-bg-gov'); + expect(ts.messages).toHaveLength(2); + expect(ts.messages[0].id).toBe('later-user-prompt'); + const banner = ts.messages[1]; + expect(banner.id).toMatch(/^gov-blocked-/); + expect(banner.extra?.governanceBlocked).toMatchObject({ + projectPath: '/work/project', + reasonKind: 'needs_confirmation', + invocationId: 'inv-new', + clientId: 'openai', + }); + expect(ts.unreadCount).toBe(1); + expect(ts.lastActivity).toBeGreaterThan(previousActivity); + }); + + it('preserves same-project governance banners for different providers and only replaces matching scope', () => { + const now = Date.now(); + useChatStore.getState().replaceThreadMessages( + 'thread-bg-gov-provider', + [ + { + id: 'anthropic-banner', + type: 'system', + variant: 'governance_blocked', + content: 'anthropic still blocked', + timestamp: now, + extra: { + governanceBlocked: { + projectPath: '/work/project', + reasonKind: 'files_missing', + invocationId: 'inv-anthropic-old', + clientId: 'anthropic', + }, + }, + }, + { + id: 'later-user-prompt', + type: 'user', + content: 'latest prompt that should stay before refreshed banner', + timestamp: now + 1, + }, + ], + true, + ); + + simulateBackgroundMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-bg-gov-provider', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'needs_confirmation', + invocationId: 'inv-openai-first', + clientId: 'openai', + }), + timestamp: now + 2, + }); + + simulateBackgroundMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-bg-gov-provider', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }), + timestamp: now + 3, + }); + + const ts = useChatStore.getState().getThreadState('thread-bg-gov-provider'); + const governanceBanners = ts.messages.filter((m) => m.variant === 'governance_blocked'); + expect(governanceBanners).toHaveLength(2); + expect(governanceBanners.map((m) => m.extra?.governanceBlocked?.clientId).sort()).toEqual([ + 'anthropic', + 'openai', + ]); + expect(governanceBanners.find((m) => m.extra?.governanceBlocked?.clientId === 'anthropic')?.id).toBe( + 'anthropic-banner', + ); + expect( + governanceBanners.find((m) => m.extra?.governanceBlocked?.clientId === 'openai')?.extra?.governanceBlocked, + ).toMatchObject({ + projectPath: '/work/project', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }); + expect(ts.messages.map((m) => m.id)).toEqual(['anthropic-banner', 'later-user-prompt', expect.any(String)]); + expect(ts.unreadCount).toBe(2); + }); + }); + describe('regression: background stream chunk merging', () => { it('requests catch-up when reducer returns catch-up for a late background stream chunk', () => { const now = Date.now(); diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts index 946a10284e..28cc4c1503 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts @@ -44,6 +44,9 @@ const mockPatchThreadMessage = vi.fn(); const mockReplaceThreadMessageId = vi.fn(); const mockReplaceThreadTargetCats = vi.fn(); const mockRemoveThreadMessage = vi.fn(); +const mockRemoveMessage = vi.fn((messageId: string) => { + storeState.messages = storeState.messages.filter((m) => m.id !== messageId); +}); const mockAppendToThreadMessage = vi.fn(); const mockAppendToolEventToThread = vi.fn(); const mockAppendRichBlockToThread = vi.fn(); @@ -65,7 +68,17 @@ const mockThreadState = { const mockGetThreadState = vi.fn(() => mockThreadState); const storeState = { - messages: [] as Array<{ id: string; type: string; catId?: string; content: string; timestamp: number }>, + messages: [] as Array<{ + id: string; + type: string; + catId?: string; + content: string; + timestamp: number; + variant?: string; + extra?: { + governanceBlocked?: { projectPath?: string; reasonKind?: string; invocationId?: string; clientId?: string }; + }; + }>, addMessage: mockAddMessage, appendToMessage: mockAppendToMessage, appendToolEvent: mockAppendToolEvent, @@ -87,8 +100,12 @@ const storeState = { replaceMessages: vi.fn((msgs: unknown[]) => { storeState.messages = msgs as typeof storeState.messages; }), - // F183 B1.7+: bg path reducer wire-up → replaceThreadMessages (thread-scoped) - replaceThreadMessages: vi.fn(), + // F183 B1.7+: reducer wire-up → replaceThreadMessages (thread-scoped) + replaceThreadMessages: vi.fn((threadId: string, msgs: typeof storeState.messages) => { + if (threadId === storeState.currentThreadId) { + storeState.messages = msgs; + } + }), incrementUnread: vi.fn(), hasMore: true, setThreadLoading: mockSetThreadLoading, @@ -101,6 +118,7 @@ const storeState = { patchThreadMessage: mockPatchThreadMessage, replaceThreadMessageId: mockReplaceThreadMessageId, replaceThreadTargetCats: mockReplaceThreadTargetCats, + removeMessage: mockRemoveMessage, removeThreadMessage: mockRemoveThreadMessage, appendToThreadMessage: mockAppendToThreadMessage, appendToolEventToThread: mockAppendToolEventToThread, @@ -255,5 +273,144 @@ describe('useAgentMessages — F173 Phase E single dispatch (KD-1 handler unific // active path must update cat status (text event → 'streaming') expect(mockSetCatStatus).toHaveBeenCalledWith('opus', 'streaming'); }); + + it('keeps same-project governance banners for different providers on the active path', () => { + storeState.currentThreadId = 'thread-active'; + storeState.messages = [ + { + id: 'anthropic-banner', + type: 'system', + variant: 'governance_blocked', + content: 'anthropic still blocked', + timestamp: 1700000000000, + extra: { + governanceBlocked: { + projectPath: '/work/project', + reasonKind: 'files_missing', + invocationId: 'inv-anthropic-old', + clientId: 'anthropic', + }, + }, + }, + ]; + mockAddMessage.mockImplementation((message) => { + storeState.messages.push(message); + }); + + act(() => { + root.render(React.createElement(Harness)); + }); + + act(() => { + captured?.handleAgentMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-active', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'needs_confirmation', + invocationId: 'inv-openai-first', + clientId: 'openai', + }), + timestamp: 1700000000001, + }); + }); + + act(() => { + captured?.handleAgentMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-active', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }), + timestamp: 1700000000002, + }); + }); + + const governanceBanners = storeState.messages.filter((m) => m.variant === 'governance_blocked'); + expect(governanceBanners).toHaveLength(2); + expect(governanceBanners.map((m) => m.extra?.governanceBlocked?.clientId).sort()).toEqual([ + 'anthropic', + 'openai', + ]); + expect(mockRemoveMessage).not.toHaveBeenCalledWith('anthropic-banner'); + expect( + governanceBanners.find((m) => m.extra?.governanceBlocked?.clientId === 'openai')?.extra?.governanceBlocked, + ).toMatchObject({ + projectPath: '/work/project', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }); + }); + + it('persists active governance banner refreshes through one thread-scoped replacement snapshot', () => { + storeState.currentThreadId = 'thread-active'; + storeState.messages = [ + { + id: 'openai-banner-old', + type: 'system', + variant: 'governance_blocked', + content: 'openai stale blocked banner', + timestamp: 1700000000000, + extra: { + governanceBlocked: { + projectPath: '/work/project', + reasonKind: 'needs_confirmation', + invocationId: 'inv-openai-old', + clientId: 'openai', + }, + }, + }, + { + id: 'user-message', + type: 'user', + content: 'keep user message', + timestamp: 1700000000001, + }, + ]; + + act(() => { + root.render(React.createElement(Harness)); + }); + + act(() => { + captured?.handleAgentMessage({ + type: 'system_info', + catId: 'codex', + threadId: 'thread-active', + content: JSON.stringify({ + type: 'governance_blocked', + projectPath: '/work/project', + reasonKind: 'files_missing', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }), + timestamp: 1700000000002, + }); + }); + + expect(mockRemoveMessage).not.toHaveBeenCalled(); + expect(mockAddMessage).not.toHaveBeenCalled(); + expect(storeState.replaceThreadMessages).toHaveBeenCalledTimes(1); + const [threadId, nextMessages, hasMore, options] = (storeState.replaceThreadMessages as ReturnType) + .mock.calls[0]; + expect(threadId).toBe('thread-active'); + expect(hasMore).toBe(true); + expect(options).toEqual({ persist: true }); + expect(nextMessages.map((m) => m.id)).toEqual(['user-message', expect.any(String)]); + expect(nextMessages[1]?.extra?.governanceBlocked).toMatchObject({ + projectPath: '/work/project', + reasonKind: 'files_missing', + invocationId: 'inv-openai-refresh', + clientId: 'openai', + }); + }); }); }); diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index b3b9a3b1f6..e2144338f9 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -494,7 +494,12 @@ export interface BackgroundStoreLike { replaceThreadMessageId: (threadId: string, fromId: string, toId: string) => void; patchThreadMessage: (threadId: string, messageId: string, patch: ChatMessagePatch) => void; /** F183 Phase B1.7 — thread-scoped reducer write entry. See chatStore.ts. */ - replaceThreadMessages: (threadId: string, msgs: ChatMessage[], hasMore?: boolean) => void; + replaceThreadMessages: ( + threadId: string, + msgs: ChatMessage[], + hasMore?: boolean, + options?: { persist?: boolean }, + ) => void; /** F183 Phase B1.7 — explicit unread bump for reducer paths that bypass * addMessageToThread's auto-increment. Used by bg error reducer wire-up * when creating a new system_status bubble for non-current thread. */ @@ -534,6 +539,28 @@ interface SystemInfoConsumeResult { variant: 'info' | 'a2a_followup'; } +function normalizeGovernanceBlockedClientId(clientId: string | undefined): string | undefined { + const normalized = clientId?.trim().toLowerCase(); + return normalized ? normalized : undefined; +} + +function isSameGovernanceBlockedScope( + message: ChatMessage, + projectPath: string, + clientId: string | undefined, +): boolean { + if (message.variant !== 'governance_blocked') return false; + const governanceBlocked = message.extra?.governanceBlocked; + if (governanceBlocked?.projectPath !== projectPath) return false; + + const existingClientId = normalizeGovernanceBlockedClientId(governanceBlocked.clientId); + const incomingClientId = normalizeGovernanceBlockedClientId(clientId); + // Older persisted banners did not carry provider scope. Treat either missing + // clientId as project-scoped so the next scoped banner can replace stale legacy UI. + if (!existingClientId || !incomingClientId) return true; + return existingClientId === incomingClientId; +} + function recoverBackgroundStreamingMessage( msg: BackgroundAgentMessage, options: HandleBackgroundMessageOptions, @@ -1062,15 +1089,17 @@ export function consumeBackgroundSystemInfo( const projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath : ''; const reasonKind = (parsed.reasonKind as string) ?? 'needs_bootstrap'; const invId = typeof parsed.invocationId === 'string' ? parsed.invocationId : undefined; - const threadMessages = options.store.getThreadState(msg.threadId).messages; - const existing = threadMessages.find( - (m: { variant?: string; extra?: { governanceBlocked?: { projectPath?: string } } }) => - m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath, + const clientId = + typeof parsed.clientId === 'string' + ? parsed.clientId + : typeof parsed.provider === 'string' + ? parsed.provider + : undefined; + const threadState = options.store.getThreadState(msg.threadId); + const nextMessagesWithoutStaleBanner = threadState.messages.filter( + (m) => !isSameGovernanceBlockedScope(m, projectPath, clientId), ); - if (existing) { - options.store.removeThreadMessage(msg.threadId, existing.id); - } - options.store.addMessageToThread(msg.threadId, { + const nextBanner: ChatMessage = { id: `gov-blocked-${msg.timestamp}-${options.nextBgSeq()}`, type: 'system', variant: 'governance_blocked', @@ -1081,9 +1110,13 @@ export function consumeBackgroundSystemInfo( projectPath, reasonKind: reasonKind as 'needs_bootstrap' | 'needs_confirmation' | 'files_missing', invocationId: invId, + clientId, }, }, - }); + }; + const nextMessages = [...nextMessagesWithoutStaleBanner, nextBanner]; + options.store.replaceThreadMessages(msg.threadId, nextMessages, threadState.hasMore, { persist: true }); + options.store.incrementUnread(msg.threadId); consumed = true; } else if (isInternalSystemInfoTelemetry(parsed)) { // Internal telemetry — suppress to avoid raw JSON bubbles in background threads @@ -5142,15 +5175,13 @@ export function useAgentMessages() { const projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath : ''; const reasonKind = (parsed.reasonKind as string) ?? 'needs_bootstrap'; const invId = typeof parsed.invocationId === 'string' ? parsed.invocationId : undefined; - const existingBlocked = useChatStore - .getState() - .messages.find( - (m) => m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath, - ); - if (existingBlocked) { - removeMessage(existingBlocked.id); - } - addMessage({ + const clientId = + typeof parsed.clientId === 'string' + ? parsed.clientId + : typeof parsed.provider === 'string' + ? parsed.provider + : undefined; + const nextBanner: ChatMessage = { id: `gov-blocked-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, type: 'system', variant: 'governance_blocked', @@ -5161,9 +5192,29 @@ export function useAgentMessages() { projectPath, reasonKind: reasonKind as 'needs_bootstrap' | 'needs_confirmation' | 'files_missing', invocationId: invId, + clientId, }, }, - }); + }; + const storeSnapshot = useChatStore.getState(); + const threadId = msg.threadId ?? storeSnapshot.currentThreadId; + if (threadId) { + const baseMessages = + threadId === storeSnapshot.currentThreadId + ? storeSnapshot.messages + : storeSnapshot.getThreadState(threadId).messages; + const nextMessages = [ + ...baseMessages.filter((m) => !isSameGovernanceBlockedScope(m, projectPath, clientId)), + nextBanner, + ]; + const nextHasMore = + threadId === storeSnapshot.currentThreadId + ? storeSnapshot.hasMore + : storeSnapshot.getThreadState(threadId).hasMore; + storeSnapshot.replaceThreadMessages(threadId, nextMessages, nextHasMore, { persist: true }); + } else { + addMessage(nextBanner); + } consumed = true; } else if (isInternalSystemInfoTelemetry(parsed)) { // Internal telemetry — suppress to avoid raw JSON bubbles diff --git a/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts b/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts new file mode 100644 index 0000000000..e4eb8a1b5e --- /dev/null +++ b/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatMessage } from '../chat-types'; + +// F070 stale-banner fix (砚砚 R2 P1) — verify removeThreadMessage now mirrors to +// threadStates AND writes the deletion to the offline snapshot so the message +// doesn't reappear on the next IDB first-paint. + +const saveThreadMessagesMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/utils/offline-store', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveThreadMessages: (threadId: string, messages: ChatMessage[], hasMore: boolean) => + saveThreadMessagesMock(threadId, messages, hasMore), + }; +}); + +const { useChatStore } = await import('../chatStore'); + +function makeMsg(id: string, content = 'hello'): ChatMessage { + return { id, type: 'user', content, timestamp: Date.now() }; +} + +function resetStore(currentThreadId: string, init: Record = {}) { + useChatStore.setState({ + messages: [], + isLoading: false, + isLoadingHistory: false, + hasMore: true, + hasActiveInvocation: false, + hasDraft: false, + intentMode: null, + targetCats: [], + catStatuses: {}, + catInvocations: {}, + currentGame: null, + threadStates: {}, + viewMode: 'single', + splitPaneThreadIds: [], + splitPaneTargetId: null, + currentThreadId, + currentProjectPath: 'default', + threads: [], + isLoadingThreads: false, + ...init, + }); +} + +describe('chatStore.removeThreadMessage — F070 stale-banner fix', () => { + beforeEach(() => { + saveThreadMessagesMock.mockReset(); + }); + + afterEach(() => { + saveThreadMessagesMock.mockReset(); + }); + + it('active thread: removes from flat messages AND mirrors to threadStates AND persists snapshot', async () => { + const banner = makeMsg('gov-blocked-1'); + const userMsg = makeMsg('user-1'); + resetStore('thread-active', { + messages: [banner, userMsg], + hasMore: false, + threadStates: { + 'thread-active': { + messages: [banner, userMsg], + hasMore: false, + isLoading: false, + isLoadingHistory: false, + hasActiveInvocation: false, + hasDraft: false, + intentMode: null, + targetCats: [], + catStatuses: {}, + catInvocations: {}, + activeInvocations: {}, + queue: [], + executionDigest: null, + } as never, + }, + }); + + useChatStore.getState().removeThreadMessage('thread-active', 'gov-blocked-1'); + + // (a) flat messages: banner gone + const flat = useChatStore.getState().messages; + expect(flat.map((m) => m.id)).toEqual(['user-1']); + + // (b) threadStates mirror: banner also gone in threadStates view + const mirrored = useChatStore.getState().threadStates['thread-active']; + expect(mirrored?.messages.map((m) => m.id)).toEqual(['user-1']); + + // (c) offline snapshot persisted — args: (threadId, nextMessages, hasMore) + // microtask-scheduled inside the action; flush. + await Promise.resolve(); + await Promise.resolve(); + expect(saveThreadMessagesMock).toHaveBeenCalledTimes(1); + const call = saveThreadMessagesMock.mock.calls[0]; + expect(call).toBeDefined(); + const [tid, msgs, hasMore] = call as unknown as [string, ChatMessage[], boolean]; + expect(tid).toBe('thread-active'); + expect(msgs.map((m) => m.id)).toEqual(['user-1']); + expect(hasMore).toBe(false); + }); + + it('background (non-active) thread: removes from threadStates AND persists snapshot', async () => { + const stalebanner = makeMsg('gov-blocked-stale'); + const other = makeMsg('other-msg'); + resetStore('thread-active', { + messages: [], + threadStates: { + 'thread-background': { + messages: [stalebanner, other], + hasMore: true, + isLoading: false, + isLoadingHistory: false, + hasActiveInvocation: false, + hasDraft: false, + intentMode: null, + targetCats: [], + catStatuses: {}, + catInvocations: {}, + activeInvocations: {}, + queue: [], + executionDigest: null, + } as never, + }, + }); + + useChatStore.getState().removeThreadMessage('thread-background', 'gov-blocked-stale'); + + // flat untouched (background thread) + expect(useChatStore.getState().messages).toEqual([]); + + // background threadStates: banner gone + const bg = useChatStore.getState().threadStates['thread-background']; + expect(bg?.messages.map((m) => m.id)).toEqual(['other-msg']); + + // offline snapshot persisted to the background thread key + await Promise.resolve(); + await Promise.resolve(); + expect(saveThreadMessagesMock).toHaveBeenCalledTimes(1); + const call = saveThreadMessagesMock.mock.calls[0]; + expect(call).toBeDefined(); + const [tid, msgs, hasMore] = call as unknown as [string, ChatMessage[], boolean]; + expect(tid).toBe('thread-background'); + expect(msgs.map((m) => m.id)).toEqual(['other-msg']); + expect(hasMore).toBe(true); + }); + + it('background replacement: persists only the final replacement snapshot, not an intermediate deletion', async () => { + const staleBanner = makeMsg('gov-blocked-stale'); + const freshBanner = makeMsg('gov-blocked-fresh'); + const other = makeMsg('other-msg'); + resetStore('thread-active', { + messages: [], + threadStates: { + 'thread-background': { + messages: [staleBanner, other], + hasMore: true, + isLoading: false, + isLoadingHistory: false, + hasActiveInvocation: false, + hasDraft: false, + intentMode: null, + targetCats: [], + catStatuses: {}, + catInvocations: {}, + activeInvocations: {}, + queue: [], + executionDigest: null, + } as never, + }, + }); + + useChatStore.getState().replaceThreadMessages('thread-background', [freshBanner, other], true, { persist: true }); + + const bg = useChatStore.getState().threadStates['thread-background']; + expect(bg?.messages.map((m) => m.id)).toEqual(['gov-blocked-fresh', 'other-msg']); + + await Promise.resolve(); + await Promise.resolve(); + expect(saveThreadMessagesMock).toHaveBeenCalledTimes(1); + const call = saveThreadMessagesMock.mock.calls[0]; + expect(call).toBeDefined(); + const [tid, msgs, hasMore] = call as unknown as [string, ChatMessage[], boolean]; + expect(tid).toBe('thread-background'); + expect(msgs.map((m) => m.id)).toEqual(['gov-blocked-fresh', 'other-msg']); + expect(hasMore).toBe(true); + expect(saveThreadMessagesMock).not.toHaveBeenCalledWith('thread-background', [other], true); + }); + + it('no-op when message id is absent: does NOT touch snapshot', async () => { + const existing = makeMsg('keep-me'); + resetStore('thread-active', { + messages: [existing], + hasMore: false, + }); + + useChatStore.getState().removeThreadMessage('thread-active', 'does-not-exist'); + + expect(useChatStore.getState().messages.map((m) => m.id)).toEqual(['keep-me']); + await Promise.resolve(); + await Promise.resolve(); + expect(saveThreadMessagesMock).not.toHaveBeenCalled(); + }); + + it('no-op when target thread has no threadStates entry: does NOT touch snapshot', async () => { + resetStore('thread-active', { messages: [] }); + + useChatStore.getState().removeThreadMessage('thread-unknown', 'any-id'); + + await Promise.resolve(); + await Promise.resolve(); + expect(saveThreadMessagesMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/stores/chat-types.ts b/packages/web/src/stores/chat-types.ts index 4f00e2ac7c..83b802c1b5 100644 --- a/packages/web/src/stores/chat-types.ts +++ b/packages/web/src/stores/chat-types.ts @@ -296,6 +296,8 @@ export interface ChatMessage { projectPath: string; reasonKind: 'needs_bootstrap' | 'needs_confirmation' | 'files_missing'; invocationId?: string; + /** Provider clientId used by the blocked dispatch preflight (e.g. openai -> AGENTS.md). */ + clientId?: string; }; /** * F173 a2a-handoff bug fix: marker for system messages that must be diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index b2bae675cc..e705d1fefb 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -312,6 +312,10 @@ export interface GlobalBubbleDefaults { cliOutput: BubbleExpandState; } +type ReplaceThreadMessagesOptions = { + persist?: boolean; +}; + /** * Resolve whether a bubble type should be expanded. * Priority: thread override > global config > fallback (collapsed). @@ -699,11 +703,17 @@ export interface ChatState { * `replaceMessages` but for arbitrary thread (current OR background). * Used by `handleBackgroundAgentMessage` to apply reducer's nextMessages * to the target thread's state. Unlike `hydrateThread` (server-authoritative - * hydration with IDB persist), this is for per-event reducer mutations: + * hydration with IDB persist), this defaults to per-event reducer mutations: * no IDB write, no hasMore-required (defaults to existing thread.hasMore), - * mirrors flat when threadId === currentThreadId. + * mirrors flat when threadId === currentThreadId. Durable system-bubble + * replacements can opt into one final snapshot write with `persist: true`. */ - replaceThreadMessages: (threadId: string, msgs: ChatMessage[], hasMore?: boolean) => void; + replaceThreadMessages: ( + threadId: string, + msgs: ChatMessage[], + hasMore?: boolean, + options?: ReplaceThreadMessagesOptions, + ) => void; replaceMessageId: (fromId: string, toId: string) => void; patchMessage: (id: string, patch: ChatMessagePatch) => void; appendToLastMessage: (content: string) => void; @@ -1664,10 +1674,10 @@ export const useChatStore = create((set, get) => ({ }, // F183 Phase B1.7 — see interface comment. - replaceThreadMessages: (threadId, msgs, hasMore) => { + replaceThreadMessages: (threadId, msgs, hasMore, options) => { // F183 Phase E AC-E2 (砚砚 R2 P1 fix): same strict-gate as replaceMessages forwardStoreInvariantViolationsStrict(msgs, threadId); - return set((state) => { + set((state) => { if (threadId === state.currentThreadId) { revokeRemovedBlobUrls(state.messages, msgs); const nextHasMore = hasMore ?? state.hasMore; @@ -1687,6 +1697,10 @@ export const useChatStore = create((set, get) => ({ }, }; }); + if (options?.persist) { + const snapshot = get().getThreadState(threadId); + void saveMessagesSnapshot(threadId, msgs, snapshot.hasMore).catch(() => {}); + } }, hydrateThread: (threadId, msgs, hasMore) => { @@ -2392,13 +2406,25 @@ export const useChatStore = create((set, get) => ({ }; }), - removeThreadMessage: (threadId, messageId) => + removeThreadMessage: (threadId, messageId) => { + // F070 stale-banner fix (砚砚 R2 P1): writer must (a) mirror to threadStates so + // KD-2 (threadStates is the writer source) holds for the active thread too, and + // (b) persist the deletion to the offline snapshot — otherwise the message comes + // right back on the next IDB first-paint (the original governance_blocked stale- + // banner symptom). + let nextHasMore: boolean | undefined; + let nextSnapshot: ChatMessage[] | undefined; set((state) => { if (threadId === state.currentThreadId) { const nextMessages = state.messages.filter((m) => m.id !== messageId); if (nextMessages.length === state.messages.length) return state; revokeRemovedBlobUrls(state.messages, nextMessages); - return { messages: nextMessages }; + nextHasMore = state.hasMore; + nextSnapshot = nextMessages; + return { + messages: nextMessages, + ...mirrorActiveFlat(state, { messages: nextMessages, hasMore: state.hasMore }), + }; } const existing = state.threadStates[threadId]; @@ -2406,6 +2432,8 @@ export const useChatStore = create((set, get) => ({ const nextMessages = existing.messages.filter((m) => m.id !== messageId); if (nextMessages.length === existing.messages.length) return state; revokeRemovedBlobUrls(existing.messages, nextMessages); + nextHasMore = existing.hasMore; + nextSnapshot = nextMessages; return { threadStates: { ...state.threadStates, @@ -2416,7 +2444,11 @@ export const useChatStore = create((set, get) => ({ }, }, }; - }), + }); + if (nextSnapshot !== undefined && nextHasMore !== undefined) { + void saveMessagesSnapshot(threadId, nextSnapshot, nextHasMore).catch(() => {}); + } + }, replaceThreadMessageId: (threadId, fromId, toId) => set((state) => { @@ -2814,7 +2846,7 @@ export const useChatStore = create((set, get) => ({ return { threadStates: { ...state.threadStates, - [threadId]: { ...ts, unreadCount: ts.unreadCount + 1 }, + [threadId]: { ...ts, unreadCount: ts.unreadCount + 1, lastActivity: Date.now() }, }, }; }),