From 35749f14aab42aa5ece31a9fa35b7353f45efe54 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.7" Date: Mon, 29 Jun 2026 09:34:58 +0800 Subject: [PATCH 01/13] fix(F070): governance banner self-heals when server reports healthy (#1045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: After `POST /api/governance/confirm` succeeds, the amber "尚未初始化治理" banner stays in zustand store + IDB snapshot — server never persisted it and no path clears it. Users see stale banners on every thread reload; clicking "重试" hits 409 "Cannot retry invocation with status 'succeeded'" because the underlying invocation completed long ago. Fix (Phase A — minimal self-healing): - GovernanceBlockedCard probes `GET /api/governance/health` on mount - When server reports projectPath as `status: 'healthy'`, fire `onSelfClear` - ChatMessage wires it to `removeThreadMessage(currentThreadId, message.id)` - Only fires while `state === 'idle'` (don't fight the in-flight bootstrap state machine if user already clicked the button) - Network failure → banner stays visible; user can still bootstrap manually Follow-ups (NOT in this PR, tracked in #1045): - Phase B: server emits `governance_unblocked` event on confirm success → frontend clears banners across all threads with that projectPath - Phase C: stop persisting `governance_blocked` to IDB; derive from (last invocation errorCode, server health) instead of stored message Co-Authored-By: Claude Opus 4.7 --- packages/web/src/components/ChatMessage.tsx | 21 +++- .../src/components/GovernanceBlockedCard.tsx | 43 ++++++- .../__tests__/governance-blocked-card.test.ts | 111 ++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/ChatMessage.tsx b/packages/web/src/components/ChatMessage.tsx index 8790c5501c..9860c49e1a 100644 --- a/packages/web/src/components/ChatMessage.tsx +++ b/packages/web/src/components/ChatMessage.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { CSSProperties } from 'react'; +import { useCallback, type CSSProperties } from 'react'; import { type CatData, formatCatName } from '@/hooks/useCatData'; import { useCoCreatorConfig } from '@/hooks/useCoCreatorConfig'; import { useTts } from '@/hooks/useTts'; @@ -103,6 +103,13 @@ 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 the active + // thread when the card's mount-time health probe confirms governance is healthy. + const removeThreadMessage = useChatStore((s) => s.removeThreadMessage); + const handleGovernanceBannerSelfClear = useCallback(() => { + if (!currentThreadId) return; + removeThreadMessage(currentThreadId, message.id); + }, [currentThreadId, message.id, removeThreadMessage]); const isUser = message.type === 'user' && !message.catId; const isSystem = message.type === 'system'; const isSummary = message.type === 'summary'; @@ -238,7 +245,17 @@ export function ChatMessage({ if (message.variant === 'governance_blocked' && message.extra?.governanceBlocked) { const { projectPath, reasonKind, invocationId } = message.extra.governanceBlocked; - return ; + // 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..72e9606639 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -6,6 +6,13 @@ interface GovernanceBlockedCardProps { projectPath: string; reasonKind: 'needs_bootstrap' | 'needs_confirmation' | 'files_missing'; invocationId?: 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 +23,16 @@ const REASON_LABELS: Record = { type CardState = 'idle' | 'confirming' | 'retrying' | 'done' | 'error'; -export function GovernanceBlockedCard({ projectPath, reasonKind, invocationId }: GovernanceBlockedCardProps) { +interface GovernanceHealthResponse { + projects?: Array<{ projectPath: string; status: string }>; +} + +export function GovernanceBlockedCard({ + projectPath, + reasonKind, + invocationId, + onSelfClear, +}: GovernanceBlockedCardProps) { const [state, setState] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -29,6 +45,31 @@ 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; + let canceled = false; + (async () => { + try { + const res = await apiFetch('/api/governance/health'); + if (canceled || !res.ok) return; + const data = (await res.json()) as GovernanceHealthResponse; + const entry = data.projects?.find((p) => p.projectPath === projectPath); + if (!canceled && entry?.status === 'healthy') { + onSelfClear(); + } + } catch { + // network failure — leave banner visible; user can still bootstrap manually + } + })(); + return () => { + canceled = true; + }; + }, [projectPath, onSelfClear, state]); + const handleBootstrap = useCallback(async () => { setState('confirming'); setErrorMsg(''); 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..fbfec66647 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,117 @@ describe('GovernanceBlockedCard', () => { expect(container.textContent).not.toContain('C:\\workspace\\tmp'); }); + it('calls onSelfClear when server reports project healthy on mount (F070 stale-banner self-heal)', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + projects: [ + { projectPath: '/test/proj', status: 'healthy' }, + { projectPath: '/other/proj', status: 'stale' }, + ], + }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + invocationId: 'inv-stale', + onSelfClear, + }), + ); + // flush mount effect microtasks + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/health'); + expect(onSelfClear).toHaveBeenCalledTimes(1); + }); + + it('does not call onSelfClear when server reports project not healthy', async () => { + const onSelfClear = vi.fn(); + mockApiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + projects: [{ projectPath: '/test/proj', status: 'never-synced' }], + }), + }); + + await act(async () => { + root.render( + React.createElement(GovernanceBlockedCard, { + projectPath: '/test/proj', + reasonKind: 'needs_bootstrap', + 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 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', + 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', + 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 () => ({}) }) From cb5a95372446b561095dedcc30770d33f5312a7b Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.7" Date: Mon, 29 Jun 2026 09:48:06 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(F070):=20scope=20banner=20self-clear?= =?UTF-8?q?=20to=20render=20thread=20+=20persist=20deletion=20(=E7=A0=9A?= =?UTF-8?q?=E7=A0=9A=20R1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address砚砚 R1 P1/P2: P1-1 — thread scoping: ChatMessage used global currentThreadId for the banner self-clear, but ChatContainer already renders non-current threads via useThreadMessages(threadId) (split-pane / background panes). With currentThreadId clearing could silently target the wrong thread on message-id collisions and fail to clear stale banners outside the active pane. - Add `threadId?: string` prop on ChatMessage (back-compat: fall back to currentThreadId when omitted). - ChatContainer.renderSingleMessage now passes `threadId={threadId}`. - Banner self-clear targets the *render* thread, not the active one. P1-2 — IDB persistence: removeThreadMessage only mutated memory. The whole point of this PR is killing a stale banner persisted in IDB; without writing the snapshot the banner returns on next first-paint hydrate. - Active-thread branch now mirrors to threadStates via mirrorActiveFlat (KD-2: threadStates is the writer source). - Both branches schedule saveMessagesSnapshot(threadId, nextMessages, hasMore) fire-and-forget so the deletion lands in IDB. - No-op early-returns (message absent / threadState absent) intentionally skip the snapshot write. P2 — import sort: reorder `useCallback, type CSSProperties` so Biome's organizeImports is satisfied (`type CSSProperties, useCallback`). Tests: - New: stores/__tests__/chatStore-remove-thread-message.test.ts (4 cases: active thread mirrors+persists, background thread persists, no-op cases). - Existing 16 governance-blocked-card + removeThreadMessage caller tests (useChatSocketCallbacks-delete, message-actions, useAgentMessages cross-thread-handoff, chatStore-multithread, chatStore-thread-runtime- writer) — 97/97 pass, no regression. Co-Authored-By: Claude Opus 4.7 --- packages/web/src/components/ChatContainer.tsx | 1 + packages/web/src/components/ChatMessage.tsx | 21 ++- .../chatStore-remove-thread-message.test.ts | 178 ++++++++++++++++++ packages/web/src/stores/chatStore.ts | 24 ++- 4 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts 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,13 +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 the active + // 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 (!currentThreadId) return; - removeThreadMessage(currentThreadId, message.id); - }, [currentThreadId, message.id, removeThreadMessage]); + 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'; 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..c1cd9743c5 --- /dev/null +++ b/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts @@ -0,0 +1,178 @@ +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 (_threadId: string, _messages: ChatMessage[], _hasMore: boolean) => {}), +); + +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('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/chatStore.ts b/packages/web/src/stores/chatStore.ts index b2bae675cc..9eb53dea9c 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -2392,13 +2392,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 +2418,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 +2430,11 @@ export const useChatStore = create((set, get) => ({ }, }, }; - }), + }); + if (nextSnapshot !== undefined && nextHasMore !== undefined) { + void saveMessagesSnapshot(threadId, nextSnapshot, nextHasMore).catch(() => {}); + } + }, replaceThreadMessageId: (threadId, fromId, toId) => set((state) => { From d1339f6e5323116e21782dccf12774989ac86b69 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Fri, 3 Jul 2026 00:07:46 +0800 Subject: [PATCH 03/13] fix(F070): satisfy build lint for banner store test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: PR #41 was blocked only by Next build treating unused mock implementation parameters as errors. Vitest records call arguments even when the mock implementation declares no parameters, so the test can keep its assertions without tripping no-unused-vars.\n\n[砚砚/GPT-5.5🐾]\nThread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex --- .../stores/__tests__/chatStore-remove-thread-message.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index c1cd9743c5..f3fae6bac1 100644 --- a/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts +++ b/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts @@ -5,9 +5,7 @@ import type { ChatMessage } from '../chat-types'; // 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 (_threadId: string, _messages: ChatMessage[], _hasMore: boolean) => {}), -); +const saveThreadMessagesMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock('@/utils/offline-store', async (importOriginal) => { const actual = await importOriginal(); From 0ea3f607291a1a07162e180466df5c6549da6f1e Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 00:43:05 +0800 Subject: [PATCH 04/13] fix: stabilize PR 41 agent provider routing gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - Fix PR #41 cloud P2 findings by normalizing routeable identities, honoring manifest providerId, and preserving approved descriptors across capability-lock activation. - Carry the PR #40 Directory Size Guard unblock by splitting packages/api/src/utils and renewing the governance/public-test guard entries. - Sync ROADMAP with active F207 so feature truth checks stay green. Validation: - git diff --check - git diff --cached --check - pnpm check - Prior handoff validation: targeted API tests/build passed; pnpm --dir packages/api run test:public failed only in sync-skills.sh due local main-worktree skill symlink state outside this PR. [砚砚/gpt-5.5🐾] --- .dir-exceptions.json | 44 ++++------ docs/ROADMAP.md | 1 + .../F023-directory-corrosion-defense.md | 2 + .../api/config/public-test-exclusions.json | 84 +++++++++---------- packages/api/src/config/ConfigRegistry.ts | 2 +- .../capabilities/capability-orchestrator.ts | 2 +- .../capabilities/capability-write-guards.ts | 2 +- .../config/capabilities/startup-cli-config.ts | 2 +- packages/api/src/config/cat-voices.ts | 2 +- .../src/config/connector-secret-updater.ts | 2 +- .../config/connector-secret-write-guards.ts | 2 +- packages/api/src/config/env-registry.ts | 2 +- .../config/governance/governance-bootstrap.ts | 4 +- .../config/governance/governance-preflight.ts | 2 +- .../config/governance/governance-registry.ts | 2 +- .../agents/invocation/InvocationTracker.ts | 2 +- .../agents/invocation/QueueProcessor.ts | 2 +- .../agents/invocation/invoke-single-cat.ts | 12 +-- .../agents/providers/ClaudeAgentService.ts | 10 +-- .../providers/ClaudeBgCarrierService.ts | 4 +- .../agents/providers/CodexAgentService.ts | 10 +-- .../agents/providers/DareAgentService.ts | 6 +- .../agents/providers/GeminiAgentService.ts | 20 ++--- .../agents/providers/KimiAgentService.ts | 8 +- .../agents/providers/OpenCodeAgentService.ts | 10 +-- .../agents/providers/acp/AcpClient.ts | 4 +- .../providers/acp/AcpHttpStreamClient.ts | 4 +- .../antigravity-image-publisher.ts | 2 +- .../providers/catagent/openai-chat-adapter.ts | 6 +- .../agents/providers/claude-agent-win.ts | 2 +- .../cli-jsonl/CliJsonlAgentService.ts | 8 +- .../agents/providers/codex-image-scanner.ts | 2 +- .../providers/generated-image-publication.ts | 4 +- .../services/agents/providers/image-paths.ts | 2 +- .../cats/services/bootcamp/workspace-root.ts | 2 +- .../cats/services/context/governance-l0.ts | 2 +- .../first-run-quest/client-detection.ts | 2 +- .../api/src/domains/cats/services/types.ts | 4 +- .../domains/plugin/PluginResourceActivator.ts | 60 +++++++------ .../domains/plugin/RoutingAdmissionService.ts | 46 ++++++++-- .../agent-provider-admission-snapshot.ts | 24 ++++-- .../plugin/agent-provider-approval-service.ts | 2 +- .../plugin/agent-provider-health-executor.ts | 2 +- .../plugin/agent-provider-projection.ts | 2 +- .../domains/terminal/tmux-agent-spawner.ts | 8 +- packages/api/src/index.ts | 12 +-- .../connectors/ConnectorRouter.ts | 2 +- .../connectors/OutboundDeliveryHook.ts | 2 +- .../connectors/connector-gateway-bootstrap.ts | 4 +- packages/api/src/routes/accounts.ts | 6 +- packages/api/src/routes/agent-hooks.ts | 2 +- packages/api/src/routes/avatars.ts | 2 +- .../api/src/routes/callback-auth-debug.ts | 2 +- .../src/routes/callback-document-routes.ts | 2 +- .../api/src/routes/callback-guide-routes.ts | 2 +- .../routes/callback-propose-thread-routes.ts | 2 +- packages/api/src/routes/callbacks.ts | 2 +- .../api/src/routes/capabilities-mcp-write.ts | 4 +- packages/api/src/routes/capabilities.ts | 6 +- packages/api/src/routes/cats.ts | 2 +- packages/api/src/routes/config-secrets.ts | 4 +- packages/api/src/routes/config.ts | 6 +- packages/api/src/routes/connector-hub.ts | 2 +- packages/api/src/routes/connector-plugins.ts | 2 +- packages/api/src/routes/first-run-quest.ts | 6 +- packages/api/src/routes/governance-status.ts | 4 +- packages/api/src/routes/image-upload.ts | 6 +- packages/api/src/routes/invocations.ts | 2 +- packages/api/src/routes/messages.ts | 4 +- packages/api/src/routes/mount-rules.ts | 8 +- packages/api/src/routes/plugin-routes.ts | 2 +- packages/api/src/routes/preview.ts | 2 +- packages/api/src/routes/projects-bootstrap.ts | 2 +- packages/api/src/routes/projects-mkdir.ts | 2 +- packages/api/src/routes/projects-setup.ts | 4 +- packages/api/src/routes/projects.ts | 7 +- .../src/routes/proposal-approve-overrides.ts | 2 +- packages/api/src/routes/push.ts | 2 +- packages/api/src/routes/quota.ts | 2 +- packages/api/src/routes/ref-audio-upload.ts | 2 +- packages/api/src/routes/rules.ts | 2 +- .../src/routes/services-lifecycle-helpers.ts | 2 +- packages/api/src/routes/skills-drift.ts | 6 +- packages/api/src/routes/skills-write.ts | 8 +- packages/api/src/routes/skills.ts | 6 +- packages/api/src/routes/threads.ts | 2 +- packages/api/src/skills/drift-detector.ts | 8 +- packages/api/src/skills/drift-resolver.ts | 2 +- packages/api/src/skills/skill-manage.ts | 2 +- packages/api/src/skills/skill-sync-engine.ts | 6 +- .../src/utils/{ => cli}/cli-diagnostics.ts | 0 .../src/utils/{ => cli}/cli-error-patterns.ts | 0 .../api/src/utils/{ => cli}/cli-format.ts | 0 .../api/src/utils/{ => cli}/cli-resolve.ts | 0 .../api/src/utils/{ => cli}/cli-spawn-win.ts | 0 packages/api/src/utils/{ => cli}/cli-spawn.ts | 14 ++-- .../api/src/utils/{ => cli}/cli-supervisor.ts | 0 .../api/src/utils/{ => cli}/cli-timeout.ts | 0 packages/api/src/utils/{ => cli}/cli-types.ts | 2 +- .../utils/{ => cli}/sanitize-cli-stderr.ts | 0 packages/api/src/utils/index.ts | 14 ++-- .../src/utils/{ => media}/image-storage.ts | 0 .../api/src/utils/{ => media}/upload-paths.ts | 2 +- .../utils/{ => network}/loopback-request.ts | 0 .../api/src/utils/{ => network}/tcp-probe.ts | 0 .../utils/{ => parsing}/jsonl-tail-reader.ts | 0 .../src/utils/{ => parsing}/ndjson-parser.ts | 0 .../utils/{ => parsing}/normalize-error.ts | 0 .../utils/{ => paths}/active-project-root.ts | 0 .../api/src/utils/{ => paths}/is-same-repo.ts | 0 .../src/utils/{ => paths}/local-override.ts | 0 .../api/src/utils/{ => paths}/memory-root.ts | 0 .../src/utils/{ => paths}/monorepo-root.ts | 0 .../api/src/utils/{ => paths}/project-path.ts | 0 .../api/src/utils/{ => paths}/startup-root.ts | 0 .../api/src/utils/privileged-route-guard.ts | 2 +- .../{ => process}/ProcessLivenessProbe.ts | 0 .../{ => process}/orphan-chrome-cleaner.ts | 0 .../utils/{ => skills}/plugin-skill-source.ts | 4 +- .../utils/{ => skills}/skill-mount-policy.ts | 0 .../api/src/utils/{ => skills}/skill-mount.ts | 4 +- .../src/utils/{ => skills}/skill-source.ts | 4 +- .../test/acp/acp-client-windows-spawn.test.js | 2 +- packages/api/test/active-project-root.test.js | 2 +- .../test/agent-provider-2b-p1-fixes.test.js | 26 ++++++ .../agent-provider-approval-service.test.js | 22 +++++ packages/api/test/agent-router.test.js | 2 +- packages/api/test/capabilities-route.test.js | 2 +- packages/api/test/cat-config-loader.test.js | 5 +- packages/api/test/catagent-phase-e.test.js | 11 ++- .../test/catagent-protocol-factory.test.js | 9 +- .../test/catagent-vendor-neutrality.test.js | 13 ++- .../test/cli-diagnostics-unknown-raw.test.js | 2 +- packages/api/test/cli-diagnostics.test.js | 4 +- packages/api/test/cli-error-patterns.test.js | 2 +- packages/api/test/cli-resolve.test.js | 2 +- .../test/cli-spawn-busy-silent-stall.test.js | 8 +- .../api/test/cli-spawn-native-exe.test.js | 2 +- packages/api/test/cli-spawn-win.test.js | 2 +- packages/api/test/cli-spawn.test.js | 26 +++--- packages/api/test/cli-timeout.test.js | 2 +- .../api/test/governance/skills-state.test.js | 6 +- packages/api/test/image-storage.test.js | 16 ++-- packages/api/test/image-upload.test.js | 2 +- .../test/integration/thread-wiring.test.js | 2 +- packages/api/test/jsonl-tail-reader.test.js | 2 +- packages/api/test/kimi-agent-service.test.js | 2 +- packages/api/test/local-override.test.js | 2 +- packages/api/test/memory-root.test.js | 2 +- packages/api/test/monorepo-root.test.js | 2 +- packages/api/test/mount-rules-route.test.js | 2 +- packages/api/test/ndjson-parser.test.js | 2 +- packages/api/test/normalize-error.test.js | 2 +- .../plugin-agent-provider-activate.test.js | 62 ++++++++++++++ .../api/test/process-liveness-probe.test.js | 4 +- packages/api/test/project-path.test.js | 2 +- .../api/test/public-test-exclusions.test.js | 6 +- .../test/routing-admission-service.test.js | 22 +++++ packages/api/test/sanitize-cli-stderr.test.js | 2 +- packages/api/test/skill-mount.test.js | 2 +- .../api/test/skills/drift-detector.test.js | 2 +- .../api/test/skills/drift-resolver.test.js | 2 +- packages/api/test/tcp-probe.test.js | 2 +- .../telemetry/cli-spawn-redaction.test.js | 2 +- .../telemetry/observability-coverage.test.js | 2 +- .../telemetry/otel-tracing-phase-b.test.js | 4 +- .../telemetry/otel-tracing-runtime.test.js | 2 +- packages/api/test/utils/is-same-repo.test.js | 2 +- .../test/utils/skill-mount-targets.test.js | 2 +- packages/api/test/utils/skill-source.test.js | 4 +- packages/web/src/components/HubCatEditor.tsx | 8 +- .../__tests__/first-run-quest-wizard.test.tsx | 4 +- .../first-run-quest/TemplateStep.tsx | 2 +- 173 files changed, 571 insertions(+), 393 deletions(-) rename packages/api/src/utils/{ => cli}/cli-diagnostics.ts (100%) rename packages/api/src/utils/{ => cli}/cli-error-patterns.ts (100%) rename packages/api/src/utils/{ => cli}/cli-format.ts (100%) rename packages/api/src/utils/{ => cli}/cli-resolve.ts (100%) rename packages/api/src/utils/{ => cli}/cli-spawn-win.ts (100%) rename packages/api/src/utils/{ => cli}/cli-spawn.ts (98%) rename packages/api/src/utils/{ => cli}/cli-supervisor.ts (100%) rename packages/api/src/utils/{ => cli}/cli-timeout.ts (100%) rename packages/api/src/utils/{ => cli}/cli-types.ts (98%) rename packages/api/src/utils/{ => cli}/sanitize-cli-stderr.ts (100%) rename packages/api/src/utils/{ => media}/image-storage.ts (100%) rename packages/api/src/utils/{ => media}/upload-paths.ts (93%) rename packages/api/src/utils/{ => network}/loopback-request.ts (100%) rename packages/api/src/utils/{ => network}/tcp-probe.ts (100%) rename packages/api/src/utils/{ => parsing}/jsonl-tail-reader.ts (100%) rename packages/api/src/utils/{ => parsing}/ndjson-parser.ts (100%) rename packages/api/src/utils/{ => parsing}/normalize-error.ts (100%) rename packages/api/src/utils/{ => paths}/active-project-root.ts (100%) rename packages/api/src/utils/{ => paths}/is-same-repo.ts (100%) rename packages/api/src/utils/{ => paths}/local-override.ts (100%) rename packages/api/src/utils/{ => paths}/memory-root.ts (100%) rename packages/api/src/utils/{ => paths}/monorepo-root.ts (100%) rename packages/api/src/utils/{ => paths}/project-path.ts (100%) rename packages/api/src/utils/{ => paths}/startup-root.ts (100%) rename packages/api/src/utils/{ => process}/ProcessLivenessProbe.ts (100%) rename packages/api/src/utils/{ => process}/orphan-chrome-cleaner.ts (100%) rename packages/api/src/utils/{ => skills}/plugin-skill-source.ts (97%) rename packages/api/src/utils/{ => skills}/skill-mount-policy.ts (100%) rename packages/api/src/utils/{ => skills}/skill-mount.ts (98%) rename packages/api/src/utils/{ => skills}/skill-source.ts (95%) 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/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..f7f7266e15 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'; 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..b63b8b83cf 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'; 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..89cbf28edb 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); diff --git a/packages/api/src/routes/image-upload.ts b/packages/api/src/routes/image-upload.ts index b5aee8ad8b..03f704f411 100644 --- a/packages/api/src/routes/image-upload.ts +++ b/packages/api/src/routes/image-upload.ts @@ -4,10 +4,10 @@ */ import { randomUUID } from 'node:crypto'; -import type { SavedImageAsset } from '../utils/image-storage.js'; -import { ImageUploadError, saveImageBufferToUploadDir } from '../utils/image-storage.js'; +import type { SavedImageAsset } from '../utils/media/image-storage.js'; +import { ImageUploadError, saveImageBufferToUploadDir } from '../utils/media/image-storage.js'; -export { ImageUploadError } from '../utils/image-storage.js'; +export { ImageUploadError } from '../utils/media/image-storage.js'; const MAX_FILES = 5; diff --git a/packages/api/src/routes/invocations.ts b/packages/api/src/routes/invocations.ts index 4eefbcdf74..7fe30fdda3 100644 --- a/packages/api/src/routes/invocations.ts +++ b/packages/api/src/routes/invocations.ts @@ -22,7 +22,7 @@ import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; export interface InvocationsRoutesOptions { invocationRecordStore: IInvocationRecordStore; diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index f7b794b8d6..5660135814 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -67,7 +67,7 @@ import { mergeTokenUsage, type TokenUsage } from '../domains/cats/services/types import { buildThreadDeepLink } from '../infrastructure/connectors/connector-command-helpers.js'; import { createModuleLogger } from '../infrastructure/logger.js'; import { buildCancelMessages, type SocketManager } from '../infrastructure/websocket/index.js'; -import { getDefaultUploadDir } from '../utils/upload-paths.js'; +import { getDefaultUploadDir } from '../utils/media/upload-paths.js'; /** F088 ISSUE-15: Minimal outbound delivery interface — avoids importing full OutboundDeliveryHook. */ interface OutboundDeliveryHookLike { @@ -97,7 +97,7 @@ interface StreamingHookLike { notifyDeliveryBatchDone?(threadId: string, chainDone: boolean): Promise; } -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 100% rename from packages/api/src/utils/orphan-chrome-cleaner.ts rename to packages/api/src/utils/process/orphan-chrome-cleaner.ts 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/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/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/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'; From 7b8f760ca8307f3186d5e9a97bed4ffada3f2338 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 00:59:41 +0800 Subject: [PATCH 05/13] fix: keep orphan chrome cleaner tests on split path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - Fix PR #41 cloud P2 feedback: orphan-chrome-cleaner.test.js imported the pre-split utils path and failed before assertions. - After restoring the source import, keep the existing orphan cleaner contract green for go-rod Chromium and cached macOS Chromium helper binaries. Validation: - RED: pnpm --dir packages/api exec node --import tsx/esm --test test/orphan-chrome-cleaner.test.js failed with ERR_MODULE_NOT_FOUND. - GREEN: pnpm --dir packages/api exec node --import tsx/esm --test test/orphan-chrome-cleaner.test.js (29 pass, 0 fail). - pnpm --dir packages/api run build - git diff --check - git diff --cached --check - rg old split-utils direct imports in packages/api/test: no matches - pnpm check - pnpm --dir packages/api test: no orphan cleaner import failure; run later timed out in test/capabilities-route.test.js at 60s, with 16408 tests / 16310 pass / 55 fail / 9 cancelled. [砚砚/gpt-5.5🐾] --- packages/api/src/utils/process/orphan-chrome-cleaner.ts | 4 +++- packages/api/test/orphan-chrome-cleaner.test.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/api/src/utils/process/orphan-chrome-cleaner.ts b/packages/api/src/utils/process/orphan-chrome-cleaner.ts index ae27e7dfe5..8608c8dbf9 100644 --- a/packages/api/src/utils/process/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/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 = { From 75ed239519c30143ab80fe668e6cae2a26460d5d Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 01:26:37 +0800 Subject: [PATCH 06/13] fix: run pr41 checks on develop base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - PR #41 targets develop under the corrected branching model, but CI and Windows Smoke only matched pull_request targets for main. - The current head therefore had zero check-runs/check-suites even after cloud review was requested. - Keep push-to-main validation unchanged for the sync bot path while allowing develop-base PR validation. Validation: - Red: gh check-runs for 7b8f760ca returned total_count=0. - ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "ok #{f}" }' .github/workflows/ci.yml .github/workflows/windows-smoke.yml - git diff --check - git diff --cached --check - pnpm check [砚砚/gpt-5.5🐾] --- .github/workflows/ci.yml | 2 +- .github/workflows/windows-smoke.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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' From 0450cf1175fb1956218b445dae0b2484194e3324 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.4" Date: Wed, 8 Jul 2026 01:45:05 +0800 Subject: [PATCH 07/13] fix: use per-project governance status for stale banner clear Why: - registry-level health can be green while a project's preflight is still blocked - GovernanceBlockedCard should only self-clear when /api/governance/status reports ready for the specific project - add regression coverage for the registry-healthy but project-blocked path --- .../src/components/GovernanceBlockedCard.tsx | 11 ++-- .../__tests__/governance-blocked-card.test.ts | 57 ++++++++++++++++--- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index 72e9606639..50083ecc5f 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -23,8 +23,8 @@ const REASON_LABELS: Record = { type CardState = 'idle' | 'confirming' | 'retrying' | 'done' | 'error'; -interface GovernanceHealthResponse { - projects?: Array<{ projectPath: string; status: string }>; +interface GovernanceStatusResponse { + ready?: boolean; } export function GovernanceBlockedCard({ @@ -54,11 +54,10 @@ export function GovernanceBlockedCard({ let canceled = false; (async () => { try { - const res = await apiFetch('/api/governance/health'); + const res = await apiFetch(`/api/governance/status?projectPath=${encodeURIComponent(projectPath)}`); if (canceled || !res.ok) return; - const data = (await res.json()) as GovernanceHealthResponse; - const entry = data.projects?.find((p) => p.projectPath === projectPath); - if (!canceled && entry?.status === 'healthy') { + const data = (await res.json()) as GovernanceStatusResponse; + if (!canceled && data.ready === true) { onSelfClear(); } } catch { 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 fbfec66647..60a906afe3 100644 --- a/packages/web/src/components/__tests__/governance-blocked-card.test.ts +++ b/packages/web/src/components/__tests__/governance-blocked-card.test.ts @@ -167,15 +167,14 @@ describe('GovernanceBlockedCard', () => { expect(container.textContent).not.toContain('C:\\workspace\\tmp'); }); - it('calls onSelfClear when server reports project healthy on mount (F070 stale-banner self-heal)', async () => { + it('calls onSelfClear when per-project governance status is ready on mount (F070 stale-banner self-heal)', async () => { const onSelfClear = vi.fn(); mockApiFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - projects: [ - { projectPath: '/test/proj', status: 'healthy' }, - { projectPath: '/other/proj', status: 'stale' }, - ], + ready: true, + needsBootstrap: false, + needsConfirmation: false, }), }); @@ -193,16 +192,18 @@ describe('GovernanceBlockedCard', () => { await Promise.resolve(); }); - expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/health'); + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj'); expect(onSelfClear).toHaveBeenCalledTimes(1); }); - it('does not call onSelfClear when server reports project not healthy', async () => { + it('does not call onSelfClear when governance status is not ready', async () => { const onSelfClear = vi.fn(); mockApiFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - projects: [{ projectPath: '/test/proj', status: 'never-synced' }], + ready: false, + needsBootstrap: true, + needsConfirmation: false, }), }); @@ -223,6 +224,46 @@ describe('GovernanceBlockedCard', () => { 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', + onSelfClear, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj'); + expect(onSelfClear).not.toHaveBeenCalled(); + }); + it('does not call onSelfClear when server omits the project', async () => { const onSelfClear = vi.fn(); mockApiFetch.mockResolvedValueOnce({ From eae97c4ac6565e8cffce411f9dc36782f5b1aba8 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 01:53:24 +0800 Subject: [PATCH 08/13] fix(web): persist governance banner replacement atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: cloud P2 found that background governance_blocked replacement persisted the remove-before-add intermediate snapshot, so reload could lose the actionable banner while dispatch remained blocked. Validation: pnpm --dir packages/web exec vitest run src/stores/__tests__/chatStore-remove-thread-message.test.ts src/hooks/__tests__/useAgentMessages-background.test.ts; pnpm check; pnpm --filter @cat-cafe/web build; git diff --check. [砚砚/gpt-5.5🐾] --- packages/web/src/hooks/useAgentMessages.ts | 26 ++++++++---- .../chatStore-remove-thread-message.test.ts | 42 +++++++++++++++++++ packages/web/src/stores/chatStore.ts | 24 ++++++++--- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index b3b9a3b1f6..935b77fe14 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. */ @@ -1062,15 +1067,12 @@ 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( + const threadState = options.store.getThreadState(msg.threadId); + const existingIndex = threadState.messages.findIndex( (m: { variant?: string; extra?: { governanceBlocked?: { projectPath?: string } } }) => m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath, ); - 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', @@ -1083,7 +1085,15 @@ export function consumeBackgroundSystemInfo( invocationId: invId, }, }, - }); + }; + const nextMessages = + existingIndex >= 0 + ? threadState.messages.map((m, idx) => (idx === existingIndex ? nextBanner : m)) + : [...threadState.messages, nextBanner]; + options.store.replaceThreadMessages(msg.threadId, nextMessages, threadState.hasMore, { persist: true }); + if (existingIndex < 0) { + options.store.incrementUnread(msg.threadId); + } consumed = true; } else if (isInternalSystemInfoTelemetry(parsed)) { // Internal telemetry — suppress to avoid raw JSON bubbles in background threads 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 index f3fae6bac1..e4eb8a1b5e 100644 --- a/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts +++ b/packages/web/src/stores/__tests__/chatStore-remove-thread-message.test.ts @@ -149,6 +149,48 @@ describe('chatStore.removeThreadMessage — F070 stale-banner fix', () => { 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', { diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 9eb53dea9c..62cfb93739 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) => { From 6038aed8b1aa2a9c0dd0756909d42a906e417fd9 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 02:09:43 +0800 Subject: [PATCH 09/13] fix(web): scope governance banner refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: cloud review found that governance self-clear probes reused an unscoped status check while dispatch preflight is provider-scoped, and refreshed background banners stayed in their old transcript slot without a new unread signal. What: carry the blocked dispatch clientId through governance_blocked payloads into the card status probe, let /api/governance/status evaluate the same provider scope, and replace stale background banners by appending the fresh banner at the transcript end with one final persisted snapshot. Validation: pnpm --dir packages/web exec vitest run src/components/__tests__/governance-blocked-card.test.ts src/hooks/__tests__/useAgentMessages-background.test.ts; pnpm --dir packages/api lint; pnpm check; pnpm --filter @cat-cafe/web build; git diff --check. [砚砚/gpt-5.5🐾] --- .../config/governance/governance-preflight.ts | 3 + .../agents/invocation/invoke-single-cat.ts | 4 +- packages/api/src/routes/governance-status.ts | 5 +- packages/web/src/components/ChatMessage.tsx | 3 +- .../src/components/GovernanceBlockedCard.tsx | 9 ++- .../__tests__/governance-blocked-card.test.ts | 44 ++++++++++++++ .../useAgentMessages-background.test.ts | 59 +++++++++++++++++++ packages/web/src/hooks/useAgentMessages.ts | 27 ++++++--- packages/web/src/stores/chat-types.ts | 2 + 9 files changed, 141 insertions(+), 15 deletions(-) diff --git a/packages/api/src/config/governance/governance-preflight.ts b/packages/api/src/config/governance/governance-preflight.ts index f7f7266e15..e90ba63d33 100644 --- a/packages/api/src/config/governance/governance-preflight.ts +++ b/packages/api/src/config/governance/governance-preflight.ts @@ -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/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index b63b8b83cf..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 @@ -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/routes/governance-status.ts b/packages/api/src/routes/governance-status.ts index 89cbf28edb..7458b4df69 100644 --- a/packages/api/src/routes/governance-status.ts +++ b/packages/api/src/routes/governance-status.ts @@ -61,7 +61,7 @@ export const governanceStatusRoute: FastifyPluginAsync ); diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index 50083ecc5f..9b218ed76b 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -6,6 +6,8 @@ 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 @@ -31,6 +33,7 @@ export function GovernanceBlockedCard({ projectPath, reasonKind, invocationId, + clientId, onSelfClear, }: GovernanceBlockedCardProps) { const [state, setState] = useState('idle'); @@ -54,7 +57,9 @@ export function GovernanceBlockedCard({ let canceled = false; (async () => { try { - const res = await apiFetch(`/api/governance/status?projectPath=${encodeURIComponent(projectPath)}`); + const params = new URLSearchParams({ projectPath }); + if (clientId) params.set('clientId', clientId); + const res = await apiFetch(`/api/governance/status?${params.toString()}`); if (canceled || !res.ok) return; const data = (await res.json()) as GovernanceStatusResponse; if (!canceled && data.ready === true) { @@ -67,7 +72,7 @@ export function GovernanceBlockedCard({ return () => { canceled = true; }; - }, [projectPath, onSelfClear, state]); + }, [projectPath, clientId, onSelfClear, state]); const handleBootstrap = useCallback(async () => { setState('confirming'); 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 60a906afe3..b1ecaacca4 100644 --- a/packages/web/src/components/__tests__/governance-blocked-card.test.ts +++ b/packages/web/src/components/__tests__/governance-blocked-card.test.ts @@ -196,6 +196,50 @@ describe('GovernanceBlockedCard', () => { expect(onSelfClear).toHaveBeenCalledTimes(1); }); + 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({ diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts index 7d597a8360..374467410d 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts @@ -772,6 +772,65 @@ 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, + ); + + 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); + }); + }); + 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/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index 935b77fe14..4a75979de4 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -1067,10 +1067,16 @@ 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 clientId = + typeof parsed.clientId === 'string' + ? parsed.clientId + : typeof parsed.provider === 'string' + ? parsed.provider + : undefined; const threadState = options.store.getThreadState(msg.threadId); - const existingIndex = threadState.messages.findIndex( + const nextMessagesWithoutStaleBanner = threadState.messages.filter( (m: { variant?: string; extra?: { governanceBlocked?: { projectPath?: string } } }) => - m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath, + !(m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath), ); const nextBanner: ChatMessage = { id: `gov-blocked-${msg.timestamp}-${options.nextBgSeq()}`, @@ -1083,17 +1089,13 @@ export function consumeBackgroundSystemInfo( projectPath, reasonKind: reasonKind as 'needs_bootstrap' | 'needs_confirmation' | 'files_missing', invocationId: invId, + clientId, }, }, }; - const nextMessages = - existingIndex >= 0 - ? threadState.messages.map((m, idx) => (idx === existingIndex ? nextBanner : m)) - : [...threadState.messages, nextBanner]; + const nextMessages = [...nextMessagesWithoutStaleBanner, nextBanner]; options.store.replaceThreadMessages(msg.threadId, nextMessages, threadState.hasMore, { persist: true }); - if (existingIndex < 0) { - options.store.incrementUnread(msg.threadId); - } + options.store.incrementUnread(msg.threadId); consumed = true; } else if (isInternalSystemInfoTelemetry(parsed)) { // Internal telemetry — suppress to avoid raw JSON bubbles in background threads @@ -5152,6 +5154,12 @@ 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 clientId = + typeof parsed.clientId === 'string' + ? parsed.clientId + : typeof parsed.provider === 'string' + ? parsed.provider + : undefined; const existingBlocked = useChatStore .getState() .messages.find( @@ -5171,6 +5179,7 @@ export function useAgentMessages() { projectPath, reasonKind: reasonKind as 'needs_bootstrap' | 'needs_confirmation' | 'files_missing', invocationId: invId, + clientId, }, }, }); 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 From 39c05751d83073f3e1624a10e46479fdbe39a23d Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 02:32:38 +0800 Subject: [PATCH 10/13] fix(web): scope governance banner replacement by provider Why: same-project governance status can differ by provider, so banner replacement must not hide a still-blocked provider CTA when another provider refreshes. --- .../useAgentMessages-background.test.ts | 80 ++++++++++++++++ .../useAgentMessages-thread-dispatch.test.ts | 92 ++++++++++++++++++- packages/web/src/hooks/useAgentMessages.ts | 29 +++++- 3 files changed, 195 insertions(+), 6 deletions(-) diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts index 374467410d..6a5676f344 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts @@ -829,6 +829,86 @@ describe('background thread socket handling', () => { }); expect(ts.unreadCount).toBe(1); }); + + 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', () => { 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..062c7480fa 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, @@ -101,6 +114,7 @@ const storeState = { patchThreadMessage: mockPatchThreadMessage, replaceThreadMessageId: mockReplaceThreadMessageId, replaceThreadTargetCats: mockReplaceThreadTargetCats, + removeMessage: mockRemoveMessage, removeThreadMessage: mockRemoveThreadMessage, appendToThreadMessage: mockAppendToThreadMessage, appendToolEventToThread: mockAppendToolEventToThread, @@ -255,5 +269,81 @@ 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', + }); + }); }); }); diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index 4a75979de4..d565614208 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -539,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, @@ -1075,8 +1097,7 @@ export function consumeBackgroundSystemInfo( : undefined; const threadState = options.store.getThreadState(msg.threadId); const nextMessagesWithoutStaleBanner = threadState.messages.filter( - (m: { variant?: string; extra?: { governanceBlocked?: { projectPath?: string } } }) => - !(m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath), + (m) => !isSameGovernanceBlockedScope(m, projectPath, clientId), ); const nextBanner: ChatMessage = { id: `gov-blocked-${msg.timestamp}-${options.nextBgSeq()}`, @@ -5162,9 +5183,7 @@ export function useAgentMessages() { : undefined; const existingBlocked = useChatStore .getState() - .messages.find( - (m) => m.variant === 'governance_blocked' && m.extra?.governanceBlocked?.projectPath === projectPath, - ); + .messages.find((m) => isSameGovernanceBlockedScope(m, projectPath, clientId)); if (existingBlocked) { removeMessage(existingBlocked.id); } From 62b4e892e4d352c7f0162e2077d1b8e3eecf502c Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 02:52:37 +0800 Subject: [PATCH 11/13] fix(web): harden governance banner self-heal scope Why: legacy unscoped governance banners cannot prove provider readiness, and active refreshes need an atomic persisted thread snapshot to avoid resurrecting stale retry prompts. --- .../src/components/GovernanceBlockedCard.tsx | 4 +- .../__tests__/governance-blocked-card.test.ts | 30 +++++++- .../useAgentMessages-thread-dispatch.test.ts | 71 ++++++++++++++++++- packages/web/src/hooks/useAgentMessages.ts | 29 +++++--- 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index 9b218ed76b..f40f398ea5 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -54,11 +54,13 @@ export function GovernanceBlockedCard({ // they've started bootstrap, the in-flight state machine owns the lifecycle. useEffect(() => { if (!onSelfClear || state !== 'idle') return; + const scopedClientId = clientId?.trim(); + if (!scopedClientId) return; let canceled = false; (async () => { try { const params = new URLSearchParams({ projectPath }); - if (clientId) params.set('clientId', clientId); + params.set('clientId', scopedClientId); const res = await apiFetch(`/api/governance/status?${params.toString()}`); if (canceled || !res.ok) return; const data = (await res.json()) as GovernanceStatusResponse; 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 b1ecaacca4..a7c12f0ac6 100644 --- a/packages/web/src/components/__tests__/governance-blocked-card.test.ts +++ b/packages/web/src/components/__tests__/governance-blocked-card.test.ts @@ -167,7 +167,7 @@ describe('GovernanceBlockedCard', () => { expect(container.textContent).not.toContain('C:\\workspace\\tmp'); }); - it('calls onSelfClear when per-project governance status is ready on mount (F070 stale-banner self-heal)', async () => { + 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, @@ -184,6 +184,7 @@ describe('GovernanceBlockedCard', () => { projectPath: '/test/proj', reasonKind: 'needs_bootstrap', invocationId: 'inv-stale', + clientId: 'openai', onSelfClear, }), ); @@ -192,10 +193,30 @@ describe('GovernanceBlockedCard', () => { await Promise.resolve(); }); - expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj'); + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai'); expect(onSelfClear).toHaveBeenCalledTimes(1); }); + it('does not self-clear legacy unscoped banners because provider readiness cannot be proven', async () => { + const onSelfClear = vi.fn(); + + 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).not.toHaveBeenCalled(); + 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) => { @@ -297,6 +318,7 @@ describe('GovernanceBlockedCard', () => { React.createElement(GovernanceBlockedCard, { projectPath: '/test/proj', reasonKind: 'needs_confirmation', + clientId: 'openai', onSelfClear, }), ); @@ -304,7 +326,7 @@ describe('GovernanceBlockedCard', () => { await Promise.resolve(); }); - expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj'); + expect(mockApiFetch).toHaveBeenCalledWith('/api/governance/status?projectPath=%2Ftest%2Fproj&clientId=openai'); expect(onSelfClear).not.toHaveBeenCalled(); }); @@ -320,6 +342,7 @@ describe('GovernanceBlockedCard', () => { React.createElement(GovernanceBlockedCard, { projectPath: '/test/proj', reasonKind: 'needs_bootstrap', + clientId: 'openai', onSelfClear, }), ); @@ -339,6 +362,7 @@ describe('GovernanceBlockedCard', () => { React.createElement(GovernanceBlockedCard, { projectPath: '/test/proj', reasonKind: 'needs_bootstrap', + clientId: 'openai', onSelfClear, }), ); 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 062c7480fa..28cc4c1503 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts @@ -100,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, @@ -345,5 +349,68 @@ describe('useAgentMessages — F173 Phase E single dispatch (KD-1 handler unific 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 d565614208..e2144338f9 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -5181,13 +5181,7 @@ export function useAgentMessages() { : typeof parsed.provider === 'string' ? parsed.provider : undefined; - const existingBlocked = useChatStore - .getState() - .messages.find((m) => isSameGovernanceBlockedScope(m, projectPath, clientId)); - if (existingBlocked) { - removeMessage(existingBlocked.id); - } - addMessage({ + const nextBanner: ChatMessage = { id: `gov-blocked-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, type: 'system', variant: 'governance_blocked', @@ -5201,7 +5195,26 @@ export function useAgentMessages() { 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 From c73696372f69f20210c8c44f8b8cef4a2bd1453c Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 03:05:10 +0800 Subject: [PATCH 12/13] fix(web): clear legacy governance banners safely Why: legacy governance_blocked messages lack provider scope, so self-heal must avoid default-provider false clears while still removing stale upgraded banners once every governance provider scope is ready. --- .../src/components/GovernanceBlockedCard.tsx | 21 ++++++--- .../__tests__/governance-blocked-card.test.ts | 46 ++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/GovernanceBlockedCard.tsx b/packages/web/src/components/GovernanceBlockedCard.tsx index f40f398ea5..1a609eed99 100644 --- a/packages/web/src/components/GovernanceBlockedCard.tsx +++ b/packages/web/src/components/GovernanceBlockedCard.tsx @@ -29,6 +29,8 @@ interface GovernanceStatusResponse { ready?: boolean; } +const LEGACY_GOVERNANCE_SELF_HEAL_CLIENT_IDS = ['anthropic', 'openai', 'google', 'kimi'] as const; + export function GovernanceBlockedCard({ projectPath, reasonKind, @@ -55,16 +57,21 @@ export function GovernanceBlockedCard({ useEffect(() => { if (!onSelfClear || state !== 'idle') return; const scopedClientId = clientId?.trim(); - if (!scopedClientId) return; + const clientIdsToProbe = scopedClientId ? [scopedClientId] : LEGACY_GOVERNANCE_SELF_HEAL_CLIENT_IDS; let canceled = false; (async () => { try { - const params = new URLSearchParams({ projectPath }); - params.set('clientId', scopedClientId); - const res = await apiFetch(`/api/governance/status?${params.toString()}`); - if (canceled || !res.ok) return; - const data = (await res.json()) as GovernanceStatusResponse; - if (!canceled && data.ready === true) { + 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 { 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 a7c12f0ac6..18d3cacefa 100644 --- a/packages/web/src/components/__tests__/governance-blocked-card.test.ts +++ b/packages/web/src/components/__tests__/governance-blocked-card.test.ts @@ -197,8 +197,16 @@ describe('GovernanceBlockedCard', () => { expect(onSelfClear).toHaveBeenCalledTimes(1); }); - it('does not self-clear legacy unscoped banners because provider readiness cannot be proven', async () => { + 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( @@ -213,7 +221,40 @@ describe('GovernanceBlockedCard', () => { await Promise.resolve(); }); - expect(mockApiFetch).not.toHaveBeenCalled(); + 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(); }); @@ -277,6 +318,7 @@ describe('GovernanceBlockedCard', () => { React.createElement(GovernanceBlockedCard, { projectPath: '/test/proj', reasonKind: 'needs_bootstrap', + clientId: 'openai', onSelfClear, }), ); From 2fdf09309ad0dc7be0df0d90a4f984ebf27bd6cd Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 8 Jul 2026 03:21:07 +0800 Subject: [PATCH 13/13] fix(web): stamp unread thread activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Background governance refreshes now use replaceThreadMessages plus incrementUnread, bypassing the old addMessageToThread activity stamp. Updating unread activity at the store boundary keeps sidebar ordering aligned with newly blocked background threads. Validation: pnpm --dir packages/web exec vitest run src/components/__tests__/governance-blocked-card.test.ts src/hooks/__tests__/useAgentMessages-background.test.ts src/hooks/__tests__/useAgentMessages-thread-dispatch.test.ts src/stores/__tests__/chatStore-remove-thread-message.test.ts Validation: pnpm check Validation: pnpm --filter @cat-cafe/web build Validation: git diff --check [砚砚/gpt-5.5🐾] Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex --- .../__tests__/useAgentMessages-background.test.ts | 12 ++++++++++++ packages/web/src/stores/chatStore.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts index 6a5676f344..836ef250f0 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-background.test.ts @@ -801,6 +801,17 @@ describe('background thread socket handling', () => { ], 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', @@ -828,6 +839,7 @@ describe('background thread socket handling', () => { 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', () => { diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 62cfb93739..e705d1fefb 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -2846,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() }, }, }; }),