From 52d45f87c1a4b9679724685391d5ce5230c344e4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:09:51 +0300 Subject: [PATCH 1/5] feat(sidebar): improve project task grouping --- .../components/sidebar/GlobalTaskList.tsx | 124 ++++++-- .../sidebar/projectGroupPagination.ts | 89 ++++++ .../components/sidebar/GlobalTaskList.test.ts | 278 ++++++++++++++++++ .../sidebar/projectGroupPagination.test.ts | 75 +++++ 4 files changed, 539 insertions(+), 27 deletions(-) create mode 100644 src/renderer/components/sidebar/projectGroupPagination.ts create mode 100644 test/renderer/components/sidebar/GlobalTaskList.test.ts create mode 100644 test/renderer/components/sidebar/projectGroupPagination.test.ts diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 188bc53f..de860219 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -33,6 +33,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal'; import { type ComboboxOption } from '../ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from './projectGroupPagination'; import { SidebarTaskItem } from './SidebarTaskItem'; import { TaskContextMenu } from './TaskContextMenu'; import { TaskFiltersPopover } from './TaskFiltersPopover'; @@ -207,6 +215,9 @@ export const GlobalTaskList = ({ const [sortPopoverOpen, setSortPopoverOpen] = useState(false); const [showArchived, setShowArchived] = useState(false); const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); @@ -221,21 +232,23 @@ export const GlobalTaskList = ({ return new Set(); } - // First load: seed all known IDs, no animations + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (isInitialTaskLoadRef.current) { isInitialTaskLoadRef.current = false; for (const t of globalTasks) { + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } return new Set(); } - // Subsequent updates: detect truly new tasks const newIds = new Set(); for (const t of globalTasks) { const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (!knownTaskIdsRef.current.has(key)) { newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(key); } } @@ -326,6 +339,11 @@ export const GlobalTaskList = ({ // Resolve project filter from filters state const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; const filtered = useMemo(() => { let result = globalTasks; @@ -345,7 +363,7 @@ export const GlobalTaskList = ({ } result = applySearch(result, searchQuery); // Archive filtering - if (showArchived) { + if (effectiveShowArchived) { result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); } else { result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); @@ -353,29 +371,16 @@ export const GlobalTaskList = ({ return result; }, [ globalTasks, - filters.projectPath, + selectedProjectPath, filters.statusIds, filters.teamName, filters.readFilter, searchQuery, readState, - showArchived, + effectiveShowArchived, taskLocalState, ]); - // Check if any archived tasks exist (before archive filtering) to conditionally show the toggle - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - - // Reset showArchived when archive becomes empty - useEffect(() => { - if (showArchived && !hasArchivedTasks) { - setShowArchived(false); - } - }, [showArchived, hasArchivedTasks]); - // Split into pinned and normal (non-pinned) tasks const pinnedTasks = useMemo( () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), @@ -400,6 +405,19 @@ export const GlobalTaskList = ({ [projectGroups] ); const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); @@ -499,7 +517,7 @@ export const GlobalTaskList = ({ {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !showArchived && ( + {pinnedTasks.length > 0 && !effectiveShowArchived && (
@@ -562,7 +580,7 @@ export const GlobalTaskList = ({ onClick={() => setShowArchived(!showArchived)} className={cn( 'rounded p-0.5 transition-colors', - showArchived + effectiveShowArchived ? 'bg-surface-raised text-text-secondary' : 'text-text-muted hover:text-text-secondary' )} @@ -571,7 +589,7 @@ export const GlobalTaskList = ({ - {showArchived ? 'Hide archived' : 'Show archived'} + {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
@@ -627,14 +645,25 @@ export const GlobalTaskList = ({ if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); const groupColor = projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); let lastTeam: string | null = null; return (
{!isGroupCollapsed && - group.tasks.map((task) => { + visibleTasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return ( @@ -691,6 +723,44 @@ export const GlobalTaskList = ({
); })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )}
); })} diff --git a/src/renderer/components/sidebar/projectGroupPagination.ts b/src/renderer/components/sidebar/projectGroupPagination.ts new file mode 100644 index 00000000..58d32732 --- /dev/null +++ b/src/renderer/components/sidebar/projectGroupPagination.ts @@ -0,0 +1,89 @@ +export const PROJECT_GROUP_PAGE_SIZE = 5; + +export interface ProjectGroupVisibilityDescriptor { + projectKey: string; + taskCount: number; +} + +export function getProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + if (taskCount <= 0) { + return 0; + } + + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + if (visibleCount == null || !Number.isFinite(visibleCount)) { + return minimumVisibleCount; + } + + const normalizedVisibleCount = Math.floor(visibleCount); + return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount)); +} + +export function getNextProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + if (currentVisibleCount >= taskCount) { + return currentVisibleCount; + } + return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE); +} + +export function getPreviousProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE); +} + +export function canProjectGroupShowMore( + visibleCount: number | undefined, + taskCount: number +): boolean { + return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount; +} + +export function canProjectGroupShowLess( + visibleCount: number | undefined, + taskCount: number +): boolean { + if (taskCount <= PROJECT_GROUP_PAGE_SIZE) { + return false; + } + return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE; +} + +export function syncProjectGroupVisibleCountByKey( + previousVisibleCountByKey: Record, + groups: readonly ProjectGroupVisibilityDescriptor[] +): Record { + let changed = false; + const nextVisibleCountByKey: Record = {}; + + for (const group of groups) { + const nextVisibleCount = getProjectGroupVisibleCount( + previousVisibleCountByKey[group.projectKey], + group.taskCount + ); + + if (nextVisibleCount > 0) { + nextVisibleCountByKey[group.projectKey] = nextVisibleCount; + } + + if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) { + changed = true; + } + } + + if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) { + changed = true; + } + + return changed ? nextVisibleCountByKey : previousVisibleCountByKey; +} diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts new file mode 100644 index 00000000..3dcacade --- /dev/null +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -0,0 +1,278 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '../../../../src/shared/types'; + +interface StoreState { + globalTasks: GlobalTask[]; + globalTasksLoading: boolean; + globalTasksInitialized: boolean; + fetchAllTasks: ReturnType; + softDeleteTask: ReturnType; + projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; + viewMode: 'flat' | 'grouped'; + repositoryGroups: { + id: string; + name: string; + totalSessions: number; + worktrees: { path: string }[]; + }[]; + teams: { teamName: string; displayName: string }[]; +} + +const storeState = {} as StoreState; +const toggleCollapsedGroup = vi.fn(); +const taskLocalState = { + isPinned: vi.fn(() => false), + isArchived: vi.fn(() => false), + getRenamedSubject: vi.fn(() => undefined), + togglePin: vi.fn(), + toggleArchive: vi.fn(), + renameTask: vi.fn(), +}; + +vi.mock('../../../../src/renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({ + useCollapsedGroups: () => ({ + isCollapsed: () => false, + toggle: toggleCollapsedGroup, + }), +})); + +vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({ + useTaskLocalState: () => taskLocalState, +})); + +vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + AnimatedHeightReveal: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({ + TaskContextMenu: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ + SidebarTaskItem: ({ task }: { task: GlobalTask }) => + React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ + TaskFiltersPopover: () => null, +})); + +vi.mock('../../../../src/renderer/components/ui/popover', () => ({ + Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Archive: Icon, + ArrowUpDown: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronRight: Icon, + Folder: Icon, + ListTodo: Icon, + Pin: Icon, + Search: Icon, + X: Icon, + }; +}); + +import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function findButton(host: HTMLElement, label: string): HTMLButtonElement | null { + return Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) ?? null; +} + +function visibleSubjects(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map( + (node) => node.textContent ?? '' + ); +} + +function makeTask(index: number, overrides: Partial = {}): GlobalTask { + const timestamp = String(60 - index).padStart(2, '0'); + return { + id: `task-${index}`, + displayId: `task${index}`, + teamName: 'alpha-team', + teamDisplayName: 'Alpha Team', + subject: `Task ${index}`, + description: '', + status: 'in_progress', + owner: 'alice', + createdAt: `2026-04-18T10:${timestamp}:00.000Z`, + updatedAt: `2026-04-18T10:${timestamp}:00.000Z`, + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + projectPath: '/workspace/hookplex', + ...overrides, + } as GlobalTask; +} + +describe('GlobalTaskList project grouping', () => { + beforeEach(() => { + storeState.globalTasks = []; + storeState.globalTasksLoading = false; + storeState.globalTasksInitialized = true; + storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined)); + storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); + storeState.projects = []; + storeState.viewMode = 'flat'; + storeState.repositoryGroups = []; + storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; + toggleCollapsedGroup.mockReset(); + taskLocalState.isPinned.mockClear(); + taskLocalState.isArchived.mockClear(); + taskLocalState.getRenamedSubject.mockClear(); + taskLocalState.togglePin.mockClear(); + taskLocalState.toggleArchive.mockClear(); + taskLocalState.renameTask.mockClear(); + localStorage.clear(); + localStorage.setItem('sidebarTasksGrouping', 'project'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + findButton(host, 'Show less')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps the hard visible limit when new tasks arrive after expansion', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(findButton(host, 'Show less')).not.toBeNull(); + + storeState.globalTasks = [ + makeTask(0, { + id: 'task-new', + displayId: 'task-new', + subject: 'Task 0', + createdAt: '2026-04-18T11:00:00.000Z', + updatedAt: '2026-04-18T11:00:00.000Z', + }), + ...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)), + ]; + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(visibleSubjects(host)).toEqual([ + 'Task 0', + 'Task 1', + 'Task 2', + 'Task 3', + 'Task 4', + 'Task 5', + 'Task 6', + 'Task 7', + 'Task 8', + 'Task 9', + ]); + expect(visibleSubjects(host)).not.toContain('Task 10'); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/sidebar/projectGroupPagination.test.ts b/test/renderer/components/sidebar/projectGroupPagination.test.ts new file mode 100644 index 00000000..b9651f04 --- /dev/null +++ b/test/renderer/components/sidebar/projectGroupPagination.test.ts @@ -0,0 +1,75 @@ +import { + PROJECT_GROUP_PAGE_SIZE, + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from '../../../../src/renderer/components/sidebar/projectGroupPagination'; +import { describe, expect, it } from 'vitest'; + +describe('projectGroupPagination', () => { + it('defaults to the first page and respects small groups', () => { + expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0); + expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3); + expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE); + }); + + it('expands in steps of five and clamps to the group size', () => { + let visibleCount = getProjectGroupVisibleCount(undefined, 17); + expect(visibleCount).toBe(5); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(10); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(15); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(17); + + expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false); + }); + + it('collapses in steps of five and never goes below the first page', () => { + expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10); + expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5); + expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5); + + expect(canProjectGroupShowLess(5, 17)).toBe(false); + expect(canProjectGroupShowLess(10, 17)).toBe(true); + }); + + it('clamps existing counts when the group shrinks and removes missing groups', () => { + const previousVisibleCounts = { + active: 15, + compact: 7, + removed: 10, + }; + + expect( + syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 9 }, + { projectKey: 'compact', taskCount: 4 }, + ]) + ).toEqual({ + active: 9, + compact: 4, + }); + }); + + it('returns the same object when nothing changes', () => { + const previousVisibleCounts = { + active: 10, + compact: 4, + }; + + const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 12 }, + { projectKey: 'compact', taskCount: 4 }, + ]); + + expect(nextVisibleCounts).toBe(previousVisibleCounts); + }); +}); From 39be3bce75e78d0ab70ac989a841ffccc75fc181 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:11:13 +0300 Subject: [PATCH 2/5] fix(team): restore task log fallback and block launch auto-assignment --- .../services/team/TeamProvisioningService.ts | 24 +- .../stream/BoardTaskLogStreamService.ts | 338 +++++++++++++++++- src/main/types/jsonl.ts | 1 + src/main/types/messages.ts | 4 + src/main/utils/jsonl.ts | 6 + .../team/task-log-stream-fallback-real.jsonl | 10 + .../BoardTaskLogStreamIntegration.test.ts | 268 +++++++++++++- .../TeamProvisioningServicePrompts.test.ts | 8 +- test/main/utils/jsonl.test.ts | 43 +++ 9 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/team/task-log-stream-fallback-real.jsonl diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 78852a60..6e4bfb47 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1910,6 +1910,7 @@ function buildDeterministicLaunchHydrationPrompt( const userPromptBlock = request.prompt?.trim() ? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const persistentContext = buildPersistentLeadContext({ teamName: request.teamName, @@ -1923,13 +1924,21 @@ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.` +${ + hasOriginalUserPrompt + ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +}` : `This reconnect/bootstrap step has already been completed deterministically by the runtime. Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn. +Use this turn only to refresh context and review the current board snapshot. +${ + hasOriginalUserPrompt + ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +} Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] @@ -1956,6 +1965,7 @@ function buildGeminiPostLaunchHydrationPrompt( const userPromptBlock = run.request.prompt?.trim() ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const teammateBootstrapSnapshot = members.length ? `Current teammate launch status:\n${members @@ -1984,8 +1994,12 @@ function buildGeminiPostLaunchHydrationPrompt( members, }); const nextStepInstruction = isSolo - ? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.' - : 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? hasOriginalUserPrompt + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + : hasOriginalUserPrompt + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; return `Gemini launch phase 2 — operating context for team "${run.teamName}". diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2a47c4fb..15157c8f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1,6 +1,9 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; +import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; @@ -16,12 +19,14 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + TeamTask, } from '@shared/types'; interface StreamSlice { id: string; timestamp: string; filePath: string; + sortOrder?: number; participantKey: string; actor: BoardTaskLogActor; actionCategory?: BoardTaskActivityCategory; @@ -37,6 +42,17 @@ interface MergedMessageAccumulator { toolUseResults: ToolUseResultData[]; } +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; +const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; +const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; +const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; + function emptyResponse(): BoardTaskLogStreamResponse { return { participants: [], @@ -49,6 +65,12 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { return { ...(detail.memberName ? { memberName: detail.memberName } : {}), @@ -691,15 +713,319 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; } +function buildToolNameByUseId( + parsedMessagesByFile: Map +): Map { + const toolNameByUseId = new Map(); + + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + + return toolNameByUseId; +} + +function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp)); + if (finiteRecordTimestamps.length === 0) { + return []; + } + + return [ + { + startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS, + endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set { + return new Set(records.map((record) => record.source.messageUuid)); +} + +function collectExplicitToolUseIds( + records: { + source: { toolUseId?: string }; + action?: { toolUseId?: string }; + }[] +): Set { + const toolUseIds = new Set(); + + for (const record of records) { + const sourceToolUseId = record.source.toolUseId?.trim(); + if (sourceToolUseId) { + toolUseIds.add(sourceToolUseId); + } + + const actionToolUseId = record.action?.toolUseId?.trim(); + if (actionToolUseId) { + toolUseIds.add(actionToolUseId); + } + } + + return toolUseIds; +} + +function collectAllowedMemberNames( + task: TeamTask, + records: { actor: { memberName?: string } }[] +): Set { + const allowedNames = new Set(); + + if (typeof task.owner === 'string' && task.owner.trim().length > 0) { + allowedNames.add(normalizeMemberName(task.owner)); + } + + for (const record of records) { + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { + allowedNames.add(normalizeMemberName(record.actor.memberName)); + } + } + + return allowedNames; +} + +function extractMessageToolUseIds(message: ParsedMessage): Set { + const toolUseIds = new Set(); + + for (const toolCall of message.toolCalls) { + if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) { + toolUseIds.add(toolCall.id.trim()); + } + } + + for (const toolResult of message.toolResults) { + if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) { + toolUseIds.add(toolResult.toolUseId.trim()); + } + } + + if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) { + toolUseIds.add(message.sourceToolUseID.trim()); + } + + return toolUseIds; +} + +function messageHasNonBoardToolActivity( + message: ParsedMessage, + toolNameByUseId: Map +): boolean { + for (const toolCall of message.toolCalls) { + if (!isBoardMcpToolName(toolCall.name)) { + return true; + } + } + + for (const toolResult of message.toolResults) { + if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) { + return true; + } + } + + if (message.sourceToolUseID) { + const sourceToolName = toolNameByUseId.get(message.sourceToolUseID); + if (sourceToolName && !isBoardMcpToolName(sourceToolName)) { + return true; + } + } + + return false; +} + +function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null { + const sessionId = message.sessionId?.trim(); + if (!sessionId) { + return null; + } + + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + const isLead = + memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName); + + return { + ...(memberName ? { memberName } : {}), + role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown', + sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function compareSlices(left: StreamSlice, right: StreamSlice): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath); + } + if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) { + return (left.sortOrder ?? 0) - (right.sortOrder ?? 0); + } + return left.id.localeCompare(right.id); +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), - private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() ) {} + private async buildInferredExecutionSlices( + teamName: string, + taskId: string, + records: Awaited>, + parsedMessagesByFile: Map + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return []; + } + + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.getContext(teamName), + ]); + + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + if (!task) { + return []; + } + + const transcriptFiles = transcriptContext?.transcriptFiles ?? []; + const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + let mergedParsedMessagesByFile = parsedMessagesByFile; + if (missingFiles.length > 0) { + const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); + mergedParsedMessagesByFile = new Map([ + ...parsedMessagesByFile.entries(), + ...additionalParsedMessages.entries(), + ]); + } + + const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile); + const recordTimestamps = records.map((record) => Date.parse(record.timestamp)); + const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps); + if (taskTimeWindows.length === 0) { + return []; + } + + const explicitMessageIds = collectExplicitMessageIds(records); + const explicitToolUseIds = collectExplicitToolUseIds(records); + const allowedMemberNames = collectAllowedMemberNames(task, records); + const leadName = + transcriptContext?.config.members + ?.find((member) => isLeadMemberCheck(member)) + ?.name?.trim() || 'team-lead'; + + const inferredSlices: StreamSlice[] = []; + for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (explicitMessageIds.has(message.uuid)) { + continue; + } + if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) { + continue; + } + + const actor = buildInferredActor(message, leadName); + if (!actor || !actor.memberName) { + continue; + } + + if ( + allowedMemberNames.size > 0 && + !allowedMemberNames.has(normalizeMemberName(actor.memberName)) + ) { + continue; + } + + const messageToolUseIds = extractMessageToolUseIds(message); + if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) { + continue; + } + if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) { + continue; + } + + inferredSlices.push({ + id: `inferred:${filePath}:${message.uuid}`, + timestamp: message.timestamp.toISOString(), + filePath, + sortOrder: index, + participantKey: buildParticipantKey(actor), + actor, + filteredMessages: [message], + }); + } + } + + return inferredSlices.sort(compareSlices); + } + async getTaskLogStream(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { return emptyResponse(); @@ -762,6 +1088,7 @@ export class BoardTaskLogStreamService { id: detail.id, timestamp: detail.timestamp, filePath: detail.source.filePath, + sortOrder: detail.source.sourceOrder, participantKey: buildParticipantKey(actor), actor, actionCategory: candidate.actionCategory, @@ -773,7 +1100,14 @@ export class BoardTaskLogStreamService { return emptyResponse(); } - const deNoisedSlices = filterReadOnlySlices(slices); + const inferredExecutionSlices = await this.buildInferredExecutionSlices( + teamName, + taskId, + records, + parsedMessagesByFile + ); + const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices); + const deNoisedSlices = filterReadOnlySlices(combinedSlices); const namedParticipantSlices = deNoisedSlices.filter((slice) => hasNamedParticipant(slice.actor) diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index bc5c2da3..c745db54 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -145,6 +145,7 @@ interface ConversationalEntry extends BaseEntry { sessionId: string; version: string; gitBranch: string; + agentName?: string; slug?: string; } diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 1b496d9c..12d87a2d 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -80,10 +80,14 @@ export interface ParsedMessage { // Metadata /** Current working directory when message was created */ cwd?: string; + /** Root/session identifier from transcript */ + sessionId?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; + /** Human-readable agent/member name from transcript */ + agentName?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 3a345d5d..17b249e6 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let model: string | undefined; let requestId: string | undefined; let cwd: string | undefined; + let sessionId: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; + let agentName: string | undefined; let isSidechain = false; let isMeta = false; let userType: string | undefined; @@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { if (isConversationalEntry(entry)) { // Common properties from ConversationalEntry base cwd = entry.cwd; + sessionId = entry.sessionId; gitBranch = entry.gitBranch; isSidechain = entry.isSidechain ?? false; userType = entry.userType; parentUuid = entry.parentUuid ?? null; + agentName = entry.agentName; // Type-specific properties if (entry.type === 'user') { @@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { model, // Metadata cwd, + sessionId, gitBranch, agentId, + agentName, isSidechain, isMeta, userType, diff --git a/test/fixtures/team/task-log-stream-fallback-real.jsonl b/test/fixtures/team/task-log-stream-fallback-real.jsonl new file mode 100644 index 00000000..63a0bd55 --- /dev/null +++ b/test/fixtures/team/task-log-stream-fallback-real.jsonl @@ -0,0 +1,10 @@ +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-start-real","timestamp":"2026-04-12T15:36:00.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-start-real","message":{"id":"msg-a-start-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","id":"call-task-start-real","name":"mcp__agent-teams__task_start","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-start-real","timestamp":"2026-04-12T15:36:00.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-start-real","sourceToolUseID":"call-task-start-real","toolUseResult":{"toolUseId":"call-task-start-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-task-start-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"idle"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-task-start-real","canonicalToolName":"task_start"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-task-start-real","content":"ok"}]}} +{"parentUuid":"u-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-real","timestamp":"2026-04-12T15:36:14.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-bash-real","message":{"id":"msg-a-bash-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":7},"content":[{"type":"tool_use","id":"call-bash-real","name":"Bash","input":{"command":"pnpm test --filter signal-ops","description":"Run targeted tests"}}]}} +{"parentUuid":"a-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-real","timestamp":"2026-04-12T15:36:14.250Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-real","sourceToolUseID":"call-bash-real","toolUseResult":{"toolUseId":"call-bash-real","stdout":"tests ok","stderr":"","exitCode":0,"content":"tests ok"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-real","content":"tests ok"}]}} +{"parentUuid":"u-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-comment-real","timestamp":"2026-04-12T15:36:30.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-comment-real","message":{"id":"msg-a-comment-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":6},"content":[{"type":"tool_use","id":"call-comment-real","name":"mcp__agent-teams__task_add_comment","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7","text":"Diagnostics complete - passing targeted checks."}}]}} +{"parentUuid":"a-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-comment-real","timestamp":"2026-04-12T15:36:30.150Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-comment-real","sourceToolUseID":"call-comment-real","toolUseResult":{"toolUseId":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-comment-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"board_action","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-comment-real","canonicalToolName":"task_add_comment","resultRefs":{"commentId":"comment-real-1"}}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"}]}} +{"parentUuid":"u-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-alice-real","timestamp":"2026-04-12T15:36:35.000Z","agentName":"alice","teamName":"beacon-desk-2","requestId":"req-bash-alice-real","message":{"id":"msg-a-bash-alice-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":4},"content":[{"type":"tool_use","id":"call-bash-alice-real","name":"Bash","input":{"command":"echo alien","description":"Unrelated command"}}]}} +{"parentUuid":"a-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-alice-real","timestamp":"2026-04-12T15:36:35.100Z","agentName":"alice","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-alice-real","sourceToolUseID":"call-bash-alice-real","toolUseResult":{"toolUseId":"call-bash-alice-real","stdout":"alien","stderr":"","exitCode":0,"content":"alien"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-alice-real","content":"alien"}]}} +{"parentUuid":"u-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-complete-real","timestamp":"2026-04-12T15:36:45.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-complete-real","message":{"id":"msg-a-complete-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"output_tokens":4},"content":[{"type":"tool_use","id":"call-complete-real","name":"mcp__agent-teams__task_complete","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-complete-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-complete-real","timestamp":"2026-04-12T15:36:45.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-complete-real","sourceToolUseID":"call-complete-real","toolUseResult":{"toolUseId":"call-complete-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-complete-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-complete-real","canonicalToolName":"task_complete"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-complete-real","content":"ok"}]}} diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index cc05960e..40355e34 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; @@ -13,6 +13,10 @@ import type { TeamTask } from '../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; +const REAL_FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/task-log-stream-fallback-real.jsonl', +); function createTask(overrides: Partial = {}): TeamTask { return { @@ -377,4 +381,266 @@ describe('BoardTaskLogStreamService integration', () => { expect(response.segments).toHaveLength(1); expect(commentResult).toBeUndefined(); }); + + it('falls back to task time-window worker logs when explicit execution links are missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:00.120Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-bash', + timestamp: '2026-04-12T15:36:14.000Z', + requestId: 'req-bash', + content: [ + { + type: 'tool_use', + id: 'call-bash', + name: 'Bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-bash', + timestamp: '2026-04-12T15:36:14.300Z', + sourceToolAssistantUUID: 'a-bash', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash', + content: 'tests ok', + }, + ], + toolUseResult: { + toolUseId: 'call-bash', + content: 'tests ok', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-complete', + content: [ + { + type: 'tool_use', + id: 'call-complete', + name: 'mcp__agent-teams__task_complete', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-complete', + timestamp: '2026-04-12T15:36:30.150Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + canonicalToolName: 'task_complete', + }, + ], + toolUseResult: { + toolUseId: 'call-complete', + content: '{"id":"c414cd52"}', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(toolNames).toContain('Bash'); + expect(toolNames).toContain('mcp__agent-teams__task_complete'); + }); + + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const bashCommands = rawMessages.flatMap((message) => + message.toolCalls + .filter((toolCall) => toolCall.name === 'Bash') + .map((toolCall) => String(toolCall.input.command ?? '')), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(bashCommands).toContain('pnpm test --filter signal-ops'); + expect(bashCommands).not.toContain('echo alien'); + expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 7eef52f4..597b31bc 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -215,6 +215,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT start implementation in this turn.'); expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain( 'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' ); @@ -473,7 +476,10 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => const prompt = extractPromptFromBootstrapFile(); expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.'); - expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.'); + expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 561de38d..3d458fdf 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -233,5 +233,48 @@ describe('jsonl', () => { expect(parsed?.uuid).toBe('bom-1'); }); + + it('preserves real transcript metadata needed by task-log fallback selection', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: 'assistant-1', + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'user-real-1', + timestamp: '2026-04-12T15:36:14.250Z', + agentName: 'tom', + isMeta: true, + sourceToolAssistantUUID: 'assistant-1', + sourceToolUseID: 'call-bash-real', + toolUseResult: { + toolUseId: 'call-bash-real', + stdout: 'tests ok', + exitCode: 0, + }, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash-real', + content: 'tests ok', + }, + ], + }, + }), + ); + + expect(parsed?.sessionId).toBe('session-real-1'); + expect(parsed?.agentName).toBe('tom'); + expect(parsed?.isMeta).toBe(true); + expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1'); + expect(parsed?.sourceToolUseID).toBe('call-bash-real'); + expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); + }); }); }); From 5a7d5ea310373642288e4d3f511aef27173d8a00 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:29:57 +0300 Subject: [PATCH 3/5] test(team): add real-jsonl coverage for task log fallback --- .../BoardTaskLogStreamIntegration.test.ts | 48 +++++++++++ .../TaskLogStreamSection.integration.test.ts | 85 ++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index 40355e34..b401c12f 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -643,4 +643,52 @@ describe('BoardTaskLogStreamService integration', () => { expect(bashCommands).not.toContain('echo alien'); expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); }); + + it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + const task = createTask({ + owner: 'tom', + createdAt: '2026-04-12T15:35:50.000Z', + updatedAt: '2026-04-12T15:37:00.000Z', + workIntervals: undefined, + }); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(rawMessages.some((message) => message.uuid === 'a-bash-real')).toBe(true); + expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); + }); }); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 732bc396..69f9bd60 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; import React, { act } from 'react'; @@ -14,6 +14,10 @@ import type { TeamTask } from '../../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; +const REAL_FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/task-log-stream-fallback-real.jsonl', +); const apiState = { getTaskLogStream: vi.fn(), @@ -105,8 +109,7 @@ function createUserEntry(args: { }; } -async function buildStreamResponse(transcriptPath: string) { - const task = createTask(); +async function buildStreamResponse(transcriptPath: string, task: TeamTask = createTask()) { const transcriptReader = new BoardTaskActivityTranscriptReader(); const recordBuilder = new BoardTaskActivityRecordBuilder(); const messages = await transcriptReader.readFiles([transcriptPath]); @@ -119,8 +122,29 @@ async function buildStreamResponse(transcriptPath: string) { messages, }), }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; - const service = new BoardTaskLogStreamService(recordSource as never); + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); return service.getTaskLogStream(TEAM_NAME, task.id); } @@ -547,4 +571,57 @@ describe('TaskLogStreamSection integration', () => { await flushMicrotasks(); }); }); + + it('renders fallback worker logs from a real-format transcript fixture and hides unrelated participant logs', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-render-real-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + apiState.getTaskLogStream.mockResolvedValueOnce( + await buildStreamResponse( + transcriptPath, + createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }), + ), + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('Bash'); + expect(text).toContain('Run targeted tests'); + expect(text).not.toContain('echo alien'); + expect(text).not.toContain('alice'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); From 8d5ee7d5ab07fb89b36f9931454caa1dbcd1fedf Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 14:12:37 +0300 Subject: [PATCH 4/5] chore(dev): sync pending branch updates --- .../plugin-kit-ai-integration-plan.md | 2880 +++++++++++++++++ .../components/sidebar/GlobalTaskList.tsx | 26 +- .../team/members/MemberMessagesTab.tsx | 24 +- .../team/messages/MessagesPanel.tsx | 30 +- src/renderer/utils/taskGrouping.ts | 4 +- 5 files changed, 2932 insertions(+), 32 deletions(-) create mode 100644 docs/extensions/plugin-kit-ai-integration-plan.md diff --git a/docs/extensions/plugin-kit-ai-integration-plan.md b/docs/extensions/plugin-kit-ai-integration-plan.md new file mode 100644 index 00000000..1960ea8a --- /dev/null +++ b/docs/extensions/plugin-kit-ai-integration-plan.md @@ -0,0 +1,2880 @@ +# plugin-kit-ai Integration Plan for Extensions Plugins + +**Status**: Draft +**Date**: 2026-04-18 +**Owner repos**: + +- `claude_team` +- `plugin-kit-ai` + +## Purpose + +Replace the current Claude-only plugin backend in `claude_team` with a provider-aware backend powered by `plugin-kit-ai`, while keeping the existing `Extensions -> Plugins` UI. + +The integration must support two different truths at the same time: + +- **Universal plugins** managed through `plugin-kit-ai` +- **Native external installed plugins** that already exist in Claude or Codex and are not yet part of universal managed state + +Those are different objects and must remain different in UI, state, and actions. + +## One-Page Summary + +### What we are building + +- keep the current `Extensions -> Plugins` UI in `claude_team` +- bundle `plugin-kit-ai` as a backend engine +- use `plugin-kit-ai` for: + - universal catalog + - native discovery + - universal lifecycle actions + +### What we are not building + +- not embedding a second plugin UI +- not parsing prose CLI output +- not scraping repo layout from `claude_team` +- not pretending native installed plugins are the same thing as universal managed plugins +- not promising `local` scope before backend really supports it + +### User-visible outcome + +- installed plugins come first +- universal plugins are the main storefront +- native installed Claude/Codex plugins stay visible and are labeled honestly +- install/update/remove/repair results stay target-granular + +### Safe delivery order + +1. add stable JSON contracts to `plugin-kit-ai` +2. add universal catalog in `plugin-kit-ai` +3. add native discovery in `plugin-kit-ai` +4. integrate read-only mixed plugin view in `claude_team` +5. add universal lifecycle actions in `claude_team` +6. consider optional native convenience flows only later + +### Non-negotiable no-go items + +- no auto-merge by display name +- no silent `local -> project` downgrade +- no destructive actions on native external entries unless backend explicitly declares them safe +- no app-side inference where backend truth is missing + +## Glossary + +### Universal plugin + +A plugin from the universal plugin catalog that can be managed through `plugin-kit-ai`. + +### Native external plugin + +A plugin that already exists in a native agent surface such as Claude or Codex, but is not part of `plugin-kit-ai` managed state. + +### Managed universal plugin + +A universal plugin that `plugin-kit-ai` has installed or is tracking in `~/.plugin-kit-ai/state.json`. + +### Catalog + +The backend surface that answers: + +- what universal plugins exist +- what targets and scopes they support +- what storefront metadata should be shown + +### Discover + +The backend surface that answers: + +- what native plugins already exist outside managed universal state +- what target and scopes they belong to +- whether the app may safely manage them + +### List + +The backend surface that answers: + +- what universal plugins are already managed + +### Doctor + +The backend surface that answers: + +- which managed universal plugins need attention because of drift, auth, or activation state + +## Hard Product Decisions + +These are fixed unless a new ADR explicitly changes them. + +### 1. Two plugin classes + +The page shows: + +- `Universal` +- `Native external installed` + +They are never silently merged. + +### 2. Installed-first ranking + +Ranking order: + +1. installed universal +2. installed native external +3. available universal + +### 3. Universal is the main storefront + +Universal plugins are the default source for new installs. +Native external plugins are primarily visibility and compatibility surfaces. + +### 4. `discover` before `adopt` + +Phase 1 needs visibility, not ownership conversion. + +### 5. No fake scope parity + +If the backend target does not support a scope, the UI must not pretend it does. + +## Definition of Done + +This migration is done only when all of the following are true: + +- `plugin-kit-ai` exposes stable machine-readable `catalog`, `discover`, and lifecycle contracts +- `claude_team` renders universal and native external entries as distinct classes +- direct Claude mode works end-to-end for universal install/update/remove/repair +- multimodel Anthropic + Codex mode works end-to-end with target-granular results +- native external plugins remain visible and truthfully labeled +- the page stays useful when one backend view fails or is stale +- rollback is possible through a feature flag without destructive cleanup + +If any of these is false, the migration is still in progress. + +## What Is Already True in plugin-kit-ai + +This plan should build on real current code, not on an imagined backend. + +### Already present today + +- `integrationctl` already exposes a public lifecycle facade +- target adapters already expose: + - `Capabilities` + - `Inspect` + - `Plan*` + - `Apply*` + - `Repair` +- post-apply verification already re-inspects the target and rejects false-positive installs +- managed lifecycle state already exists in `~/.plugin-kit-ai/state.json` +- read-only managed views already exist conceptually: + - `list` + - `doctor` +- lifecycle update/remove already re-resolve the source and reject identity drift if the resolved manifest no longer matches the stored `integration_id` + +### Important current gaps + +- `integrations` CLI currently prints prose, not versioned JSON +- there is no public `catalog` surface yet +- there is no public `discover` surface yet +- current managed `Report.Targets` do not carry enough integration-level context for the app +- current service composition still depends on `os.Getwd()` for workspace semantics + +### Important current model split + +There are two useful metadata layers today: + +- the richer authored plugin model used by `pluginmodel` / `pluginmanifest` +- the narrower `integrationctl.IntegrationManifest` + +Current `integrationctl` manifest loading preserves: + +- name +- version +- description +- targets +- derived deliveries +- derived capability surface + +But it currently drops richer authored metadata such as: + +- homepage +- repository +- keywords +- author +- license + +Practical consequence: + +- lifecycle can already use the current `IntegrationManifest` +- storefront catalog cannot get all desired detail fields from the current `IntegrationManifest` alone + +### Packaging nuance from current code + +- evidence registry already has an embedded fallback, which lowers packaging risk +- workspace-lock storage is still repo-root oriented + +Practical consequence: + +- `list`, `doctor`, `add`, `update`, `remove`, and `repair` are the right first app surfaces +- `sync` is not a phase-1 or phase-2 app surface +- `enable` and `disable` can stay out of the first app rollout + +## Current Adapter Truth From Code + +These are backend facts the plan must respect. + +### Claude adapter + +- install mode: `native_cli` +- supports native update: yes +- supports native remove: yes +- supports scopes: `user`, `project` +- does not currently advertise `local` +- requires reload after install + +### Codex adapter + +- install mode: `marketplace_prepare` +- supports native update: no +- supports native remove: no +- supports scopes: `user`, `project` +- does not currently advertise `local` +- requires restart and a new thread +- current inspect logic distinguishes: + - fully installed + - disabled + - prepared but not activated + - degraded + +### Consequence for the app + +The app must treat scope support as backend-owned truth. + +That means: + +- phase 1 and phase 2 should expose only `user` and `project` for plugin-kit-backed universal installs +- if `local` is important later, it must be added as a real backend capability first + +## Architecture Boundary + +### Correct boundary + +- `plugin-kit-ai` = lifecycle engine, universal catalog backend, native discovery backend +- `claude_team` = frontend, state, UX, feature-flagged rollout + +### Wrong boundaries + +- do not embed a second plugin UI +- do not parse human CLI output +- do not scrape universal repo layout directly in `claude_team` +- do not link Go code directly into Electron instead of using the CLI contract + +## Recommended Backend Basis By Surface + +Different backend surfaces should be built on different existing code paths. +Trying to force one internal model to answer every question would make the result worse. + +| Surface | Best current basis in `plugin-kit-ai` | Why | +|---|---|---| +| `catalog` | `pluginmanifest.Inspect` + `publicationmodel` + `targetcontracts` | richer authored metadata and stronger target/output truth | +| `list` | `integrationctl` managed state and current `list` service | managed universal truth already exists here | +| `doctor` | `integrationctl` current `doctor` service | managed drift/auth/activation truth already exists here | +| lifecycle mutate | `integrationctl` facade and adapters | real install/update/remove/repair engine already exists here | +| `discover` | new dedicated native discovery layer built using native surface readers and inspect helpers | external observed truth is different from managed lifecycle truth | + +### Recommended decision + +- build `catalog` from the richer authored-model path +- build `list`, `doctor`, and lifecycle mutations from `integrationctl` +- build `discover` as a new surface that may reuse inspect helpers, but is not just `Inspect` + +This is the cleanest way to avoid overloading one narrow model with too many jobs. + +## Recommended plugin-kit-ai Implementation Ownership + +One of the highest-risk failure modes is building the right contract in the wrong layer. +This section fixes ownership up front. + +### Chosen seam by package + +| Concern | Recommended home in `plugin-kit-ai` | Why | +|---|---|---| +| CLI flags, `--format json`, envelopes, exit semantics | `cli/plugin-kit-ai/cmd/plugin-kit-ai` | command boundary belongs here, not business truth | +| catalog projection for app consumption | new helper near `cli/plugin-kit-ai/internal/pluginmanifest` such as `internal/catalogview` | catalog truth comes from authored inspection and needs a stable projection layer | +| managed lifecycle grouping and integration-level fields | `install/integrationctl/domain` + `install/integrationctl/usecase` | lifecycle truth already lives here and should not be reconstructed in the CLI | +| native discovery orchestration | new discovery usecase under `install/integrationctl/usecase` with types in `install/integrationctl/domain` | discovery is backend truth, not a frontend heuristic | +| target-specific native enumeration and evidence collection | existing adapter packages under `install/integrationctl/adapters/` | adapters already own target-specific path and native-surface knowledge | +| source resolution | `install/integrationctl/adapters/source` | canonical source resolution already lives here | +| workspace-root normalization into target-specific native roots | `install/integrationctl/adapters/pathpolicy` plus adapter path helpers | app must not duplicate `ProjectRoot` vs `EffectiveGitRoot` logic | + +### Raw internal models must not leak as app contracts + +The app should not consume any of these raw internal shapes directly: + +- raw `pluginmanifest.Inspection` +- raw `integrationctl` lifecycle `domain.Report` +- raw adapter `InspectResult` + +Instead: + +- CLI commands should project them into stable app-facing JSON contracts +- projection should happen once in `plugin-kit-ai` +- `claude_team` should consume only those projected contracts + +### Why this chosen seam is safer + +- it keeps target/path/source truth backend-owned +- it avoids Electron reconstructing lifecycle groupings or source provenance +- it allows `plugin-kit-ai` to change internal models without breaking the app contract +- it makes E2E failures easier to localize to one layer + +## Which Backend Surface Answers Which UI Question + +| UI question | Backend surface | Why | +|---|---|---| +| What universal plugins can I install? | `catalog` | storefront availability | +| What universal plugins are already managed? | `list` | managed truth | +| What managed universal plugins need attention? | `doctor` | drift, auth, activation | +| What native plugins already exist outside plugin-kit management? | `discover` | observed external truth | +| Can this plugin be installed for Claude? | `catalog` capability + scope metadata | install intent | +| Can this plugin be installed for Codex? | `catalog` capability + scope metadata | install intent | +| Can I safely uninstall this native external plugin from the app? | `discover.manageability` | destructive authority must come from backend | +| What exactly happened after install/update/remove? | lifecycle result JSON | target-granular mutation truth | + +Rule: + +- if the answer is not available from one of these surfaces, the app should not invent it + +## Backend View Consistency Matrix + +This section makes cross-surface ownership explicit. +It should be possible to answer every “which surface wins?” question from this table alone. + +| Field or question | Winning surface | Allowed fallback | Forbidden fallback | +|---|---|---| +| managed existence | `list` | none | `catalog`, `discover`, app heuristics | +| managed health / degraded / auth-pending | `doctor` | `list` only for neutral installed state | `catalog`, `discover` | +| universal availability | `catalog` | stale cached catalog with explicit stale marker | `discover`, app heuristics | +| native external existence | `discover` | stale cached discovery with explicit stale marker | `catalog`, `list` | +| destructive authority for native external entries | `discover.manageability` | none | app heuristics | +| installability by target/scope | `catalog` plus lifecycle capability projection | conservative disable in app | inferred support from authored target alone | +| managed target result after mutation | lifecycle mutate result | immediate `doctor` refresh | app optimism without payload evidence | +| storefront detail metadata | `catalog` detail projection | explicit missing-detail UI | generated operational docs | + +### Required contradiction handling + +If surfaces disagree: + +- `list` vs `catalog` + - keep the managed entry + - mark catalog/detail support degraded if needed +- `discover` vs `catalog` + - keep both truths + - do not rewrite ownership +- `doctor` vs `list` + - prefer `doctor` for health state + - prefer `list` for managed existence +- lifecycle mutate result vs stale cached `list` + - prefer the fresh mutate payload, then refetch + - do not let stale cache overwrite the mutation outcome + +## Data Flow + +```mermaid +flowchart LR + A["universal-plugins-for-ai-agents"] --> B["plugin-kit-ai catalog"] + C["Claude native surfaces"] --> D["plugin-kit-ai discover"] + E["Codex native surfaces"] --> D + F["plugin-kit-ai state.json"] --> G["plugin-kit-ai list"] + F --> H["plugin-kit-ai doctor"] + I["plugin-kit-ai lifecycle actions"] --> F + B --> J["claude_team Plugins UI"] + D --> J + G --> J + H --> J + J --> I +``` + +## Target Naming and Mapping + +Authored target names and app-facing provider labels are not always the same thing. + +Examples from current code: + +- authored `claude` maps to app/runtime target `claude` +- authored `codex-package` maps to app/runtime target `codex` +- authored `gemini` maps to app/runtime target `gemini` +- authored `cursor` maps to app/runtime target `cursor` +- authored `opencode` maps to app/runtime target `opencode` + +### Surface-specific target id rule + +The same plugin may be described through different target vocabularies depending on the backend surface: + +- authored/catalog truth: + - `claude` + - `codex-package` + - `codex-runtime` + - `gemini` + - `cursor` + - `cursor-workspace` + - `opencode` +- lifecycle-manageable truth: + - `claude` + - `codex` + - `gemini` + - `cursor` + - `opencode` +- app-facing provider labeling: + - `Anthropic` + - `Codex` + - optionally later other providers + +This is already visible in current code: + +- authored plugin metadata and `pluginmanifest` preserve `codex-package` +- `integrationctl` normalizes that into lifecycle target `codex` +- the UI should render a provider lane label such as `Codex`, not leak raw lifecycle ids everywhere + +Recommended rule: + +- never force one single `target` field to carry all three meanings +- preserve target ids separately by surface +- use explicit fields such as: + - `authored_targets` + - `manageable_targets` + - `available_app_targets` + +### Recommended catalog rule + +Catalog entries should preserve both: + +- authored target identifiers +- normalized app/runtime target identifiers + +Why: + +- authored compatibility and generated outputs still care about authored target names +- the app needs stable provider-level labels like `Anthropic` and `Codex` +- this avoids lossy translation + +## App-Facing Target Subset + +`plugin-kit-ai` understands more targets than the `claude_team` plugin page needs to action directly. + +For this integration, the primary app-facing actionable subset should be: + +- `claude` +- `codex-package` + +These map to the current app-facing provider lanes: + +- `Anthropic` +- `Codex` + +### Out of scope for first actionability + +These may still exist in authored metadata, but they should not drive primary install buttons in the first rollout: + +- `codex-runtime` +- `gemini` +- `cursor` +- `cursor-workspace` +- `opencode` + +### Recommended UI rule + +- the backend catalog may preserve full authored target support +- `claude_team` should derive primary provider labels and actions only from the app-relevant subset +- broader target support may appear as secondary detail later, but should not confuse the main install surface + +### Important nuance + +This is not because those other targets are “fake”. +It is because this app rollout has a narrower action surface than the full authored/plugin backend target space. + +For example: + +- `plugin-kit-ai` lifecycle already knows targets like `gemini`, `cursor`, and `opencode` +- `pluginmanifest` and `targetcontracts` know even broader authored/runtime distinctions such as `codex-package` vs `codex-runtime` + +But the first plugin page rollout in `claude_team` should optimize for a clear and reliable main surface, not for exposing the entire backend target universe at once. + +## Catalog Support Projection Rules + +Catalog generation should preserve three different truths without collapsing them: + +### 1. Authored targets + +What the plugin repo declares in `plugin.yaml`. + +Examples: + +- `claude` +- `codex-package` +- `codex-runtime` +- `gemini` +- `cursor` +- `cursor-workspace` +- `opencode` + +### 2. Backend-manageable lifecycle targets + +What the current `integrationctl` lifecycle can actually manage today. + +From current code, that target set is: + +- `claude` +- `codex-package` +- `gemini` +- `cursor` +- `opencode` + +Notably, it does **not** include: + +- `codex-runtime` +- `cursor-workspace` + +### 3. App-primary action targets + +What `claude_team` should expose as first-class install lanes in this rollout. + +Recommended set: + +- `claude` +- `codex-package` + +### Current public target universe in `plugin-kit-ai` + +From current `platformmeta` code, the public target universe is already split into: + +- packaged profiles: + - `claude` + - `codex-package` + - `codex-runtime` +- tooling profiles: + - `gemini` + - `cursor` + - `cursor-workspace` + - `opencode` + +This is useful context because it shows that the backend target universe is intentionally broader than the first plugin-page rollout. + +### Required contract rule + +A catalog entry should be able to preserve all three layers separately, for example: + +- `authored_targets` +- `manageable_targets` +- `primary_action_targets` + +This keeps the system honest: + +- authored truth stays intact +- backend actionability stays explicit +- app UI stays focused + +## Product Model + +### Universal plugins + +Source: + +- [universal-plugins-for-ai-agents/plugins](https://github.com/777genius/universal-plugins-for-ai-agents/tree/main/plugins) + +Properties: + +- installable through `plugin-kit-ai` +- available for one or more targets +- primary browse/search source +- explicit target support labels + +### Native external installed plugins + +Properties: + +- already installed in a native agent surface +- not necessarily managed by `plugin-kit-ai` +- still visible in UI +- clearly labeled by target ownership + +Example labels: + +- `Installed - Anthropic only` +- `Installed - Codex only` + +## Source of Truth Model + +| Surface | Owner | Meaning | +|---|---|---| +| `catalog` | `plugin-kit-ai` | what universal plugins are available | +| `discover` | `plugin-kit-ai` | what native plugins are already observed | +| `list` | `plugin-kit-ai` | what universal plugins are managed | +| `doctor` | `plugin-kit-ai` | health and drift for managed universal plugins | +| renderer cache | `claude_team` | temporary UI cache only | + +### Consistency rules + +- every rendered `universal_installed` entry must be explainable by `list` +- every degraded managed universal entry must be explainable by `doctor` +- `discover` may overlap conceptually with universal entries, but never redefines managed ownership +- `catalog` must not advertise target/scope support that lifecycle will reject under normal supported conditions + +### Freshness and partial-view rules + +These rules matter because `catalog`, `discover`, `list`, and `doctor` do not have the same source or refresh cost. + +- `list` is authoritative for managed existence, even if `catalog` is stale or temporarily missing an entry +- `doctor` is authoritative for managed health, even if `catalog` is stale +- `discover` is authoritative for observed native external existence, unless suppressed by stronger managed-overlap evidence +- failed or stale `catalog` must not make a managed entry disappear from the page +- failed or stale `discover` must not invent that native external entries were removed +- the app may mark data stale, but it must not rewrite ownership because one backend view is temporarily unavailable + +### Consistency invariants the backend contract should preserve + +1. every managed entry returned by `doctor` must also be representable in `list` by the same managed grouping key +2. `discover` must either suppress managed overlap or mark it explicitly, but must not silently contradict `list` +3. `catalog` may omit optional storefront metadata, but must not change canonical `integration_id` +4. `claude_team` must never resolve a contradiction by guessing - it should preserve both truths and degrade the UI honestly + +## Identity, Matching, and Dedupe + +### Universal identity + +Canonical key: + +- `integration_id` + +### Native external identity + +Canonical key: + +- `native_target + native_plugin_id + scope-set` + +### Matching rule + +`matched_integration_id` is advisory only. +It does not convert a native external entry into a universal entry. + +### Match confidence + +Recommended values: + +- `match_confidence`: `exact | heuristic | none` +- `match_basis`: `same_repo_same_plugin_id | same_marketplace_identity | manual_mapping | name_heuristic | unknown` + +### Target-specific matching ladder + +The backend should classify matches conservatively. + +Recommended ladder: + +#### Claude native external -> universal + +`exact` only when the backend can prove the same marketplace identity, for example: + +- same native plugin ref +- or same marketplace identity pair such as: + - plugin id + - marketplace name + +`heuristic` only when: + +- display name matches strongly +- and target is the same +- and there is no stronger conflicting candidate + +`none` when: + +- only loose name similarity exists +- or more than one universal plugin could plausibly match + +Important note: + +- current managed Claude installs use synthetic refs such as `integration_id@integrationctl-` +- native external Claude installs from official or third-party marketplaces may use different marketplace identities +- therefore name equality alone is not enough for `exact` + +#### Codex native external -> universal + +`exact` only when the backend can prove the same native plugin identity, for example: + +- marketplace entry name equals integration id +- and the same plugin reference is observed consistently in: + - marketplace catalog + - config toggle ref + - or managed plugin root path + +`heuristic` only when: + +- marketplace entry name strongly matches integration id +- but not every supporting surface is available + +`none` when: + +- only title-level similarity exists +- or multiple universal integrations could map to the same observed native name + +Important note: + +- current Codex-managed installs use `integration_id` as the marketplace entry name +- they also create related evidence in: + - `.agents/plugins/marketplace.json` + - plugin root path under `plugins/` + - config plugin ref `@` +- that is good raw evidence, but the backend should still keep external discovery conservative + +### Preferred matching evidence by target + +| Target | Strongest evidence | Weaker evidence | Unsafe alone | +|---|---|---|---| +| Claude | plugin ref, marketplace name + plugin id pair | stable display name + target | display name alone | +| Codex | marketplace entry name + config plugin ref + plugin root agreement | marketplace entry name only | title similarity alone | + +Recommended rule: + +- only the strongest evidence column may justify `exact` +- weaker evidence may justify `heuristic` +- the unsafe column must map to `none` + +### Matching invariants + +- `exact` must be explainable from stable identity-bearing fields, not from display text +- `heuristic` must never unlock destructive actions +- `heuristic` must never collapse two entries into one +- if confidence is below `exact`, the UI should treat the relation as advisory only +- the app must not recalculate confidence differently from the backend +- overlap suppression and matching are different decisions +- an entry may be suppressed as managed overlap without ever being exposed as a native external match candidate + +Renderer rule: + +- only strong bases may drive stronger UI hints +- heuristic matches must never auto-merge entries or unlock stronger actions + +## Catalog Production Model + +This must be explicit because the universal repo is the source of truth for available universal plugins, but it must not become the app contract directly. + +### Source of universal entries + +Universal catalog entries should be generated from: + +- `777genius/universal-plugins-for-ai-agents/plugins/*` + +using `plugin-kit-ai`, not using app-side parsing. + +### Recommended pipeline + +1. select a pinned revision of `universal-plugins-for-ai-agents` +2. enumerate plugin directories under `plugins/*` +3. load each plugin through the richer authored plugin model, ideally the same `pluginmanifest.Inspect` path that already exposes: + - manifest metadata + - publication model + - target contract details +4. derive normalized catalog entries +5. write a bundled snapshot for app packaging +6. optionally refresh from the same source later + +### Why `publicationmodel` is helpful but not enough on its own + +Current `publicationmodel.Model` is useful for catalog generation because it already normalizes: + +- package targets +- package families +- channel families +- install model +- authored docs +- managed artifacts + +But by itself it does **not** carry the full storefront metadata set the app wants, such as: + +- homepage +- repository +- keywords +- author +- license + +Those live in the richer authored manifest path exposed through `pluginmanifest.Inspection.Manifest`. + +Recommended rule: + +- catalog generation should use `pluginmanifest.Inspect` as the main authored inspection entry point +- then combine: + - `Inspection.Manifest` for storefront metadata + - `Inspection.Publication` for publication/channel/package projection + - `Inspection.Targets` plus target contracts for support and surface details + +The app must not try to reconstruct that combination on its own. + +### Why this should not use the current narrow lifecycle loader only + +The current `integrationctl` manifest loader is enough for lifecycle planning, but it preserves only: + +- name +- version +- description +- targets +- derived deliveries + +That is not enough on its own for the desired storefront contract. + +Recommended resolution: + +- treat catalog generation as its own backend translation layer +- allow that layer to read richer authored metadata +- still keep the final catalog output normalized and versioned + +### Why `pluginmanifest.Inspect` is a better catalog basis than the current lifecycle loader + +From current code, `pluginmanifest.Inspect` already carries much richer input than `integrationctl.Loader`, including: + +- authored manifest metadata +- publication model +- target contract fields such as install model, activation model, native root, portable kinds, native surfaces, and managed artifacts + +That makes it a much better source for storefront and support badges. + +### Performance boundary + +Current source resolution for lifecycle work may clone GitHub or git URL sources. +That is acceptable for install/update style mutations. + +It is not acceptable as the way to build the storefront catalog. + +Catalog generation should work from: + +- a pinned local checkout +- a prepared snapshot +- or another batch-friendly backend path + +It should not resolve each storefront entry by cloning sources independently at runtime. + +### Alias rule + +The first-party alias map is useful for CLI shortcuts like `plugin-kit-ai add notion`. +It is not the storefront contract. + +## Source Reference Semantics + +Source semantics must stay explicit. +This is another place where the app should not invent meaning. + +### Current lifecycle source truth + +From current `integrationctl` code, lifecycle source resolution already distinguishes: + +- requested source ref +- resolved source ref +- local materialized path +- source digest + +Examples: + +- local path request: + - requested kind `local_path` + - resolved kind `local_path` +- GitHub repo-path request: + - requested kind `github_repo_path` + - resolved kind `git_commit` +- git URL request: + - requested kind `git_url` + - resolved kind `git_commit` + +This is good and should be preserved in the app-facing lifecycle contract. + +### Alias semantics + +Current first-party aliases are only convenience input forms such as: + +- `context7` +- `stripe` +- `notion` + +They resolve to concrete GitHub repo-path refs under the universal plugin repository. + +Recommended rule: + +- aliases are accepted CLI input +- aliases are not canonical identity +- aliases should not become the only stored source value in app state + +### Catalog source semantics + +Catalog source semantics are different from lifecycle source semantics. + +For the first app rollout, catalog entries should be treated as coming from a curated catalog snapshot with its own provenance, for example: + +- snapshot source kind +- catalog revision +- generated-by backend version + +The catalog should not pretend that every card was individually resolved through runtime lifecycle source resolution. + +### Recommended contract rule + +Keep these source layers separate: + +- `requested_source_ref` + - lifecycle input truth +- `resolved_source_ref` + - lifecycle resolved truth +- `catalog_source` + - storefront snapshot provenance + +The app should never collapse those into one ambiguous `source` string. + +### Recommended UI rule + +- install detail may show requested and resolved source refs for managed universal installs +- storefront cards should usually show catalog provenance only when needed for debugging or advanced detail +- aliases may be accepted in user-facing install flows, but the stored and rendered lifecycle truth should remain normalized requested/resolved refs + +## Workspace Root and Project Scope Semantics + +Project-scoped installs need one more rule-set because current target adapters do not all interpret project roots the same way. + +### Current code reality + +`plugin-kit-ai` already distinguishes: + +- user scope +- project scope +- stored `workspace_root` on managed installation records + +But current adapters derive effective native roots differently: + +- `Claude` + - project settings path uses `ProjectRoot(workspace_root, project_root)` +- `Codex` + - project marketplace root uses `EffectiveGitRoot(workspace_root, project_root)` +- `OpenCode` + - project assets/config roots also use `EffectiveGitRoot(workspace_root, project_root)` +- `Cursor` + - project config path currently uses `ProjectRoot(workspace_root, project_root)` + +This means the same raw workspace path can lead to different effective native roots depending on target semantics. + +### Why this matters + +If the app assumes one global meaning for `workspace_root`, it can easily: + +- install into the wrong repo root +- render the wrong project target path in detail +- refresh the wrong context after mutation +- mis-explain project scope differences between providers + +### Recommended rule + +- the app passes the raw user-selected `workspace_root` +- the backend owns target-specific effective-root normalization +- the app must not try to emulate `EffectiveGitRoot` or `ProjectRoot` logic itself + +### Recommended contract additions + +Where useful, lifecycle and discovery payloads may expose explicit derived fields such as: + +- `workspace_root` + - raw project context that was requested or persisted +- `effective_native_root` + - target-specific derived root actually used for native files +- `native_scope_root` + - optional friendlier alias if that reads better in the contract + +Phase-1 minimum: + +- `workspace_root` is required for project-scoped managed installs +- missing project `workspace_root` must remain a hard backend error +- target-specific effective root may be additive if not ready immediately + +### Recommended app rule + +- UI selection should talk in terms of the chosen project/workspace +- backend detail and diagnostics may show the effective native root when it differs +- app logic for mutation, refresh, and cache keys should continue to use the raw workspace context plus target, not a home-grown rewritten root + +### Why this should stay backend-owned + +Current target adapters already encode platform-specific expectations. + +Examples: + +- Codex project installs intentionally anchor to effective git root +- Claude project installs intentionally target project-local settings path + +Trying to centralize those rules in Electron would duplicate platform policy and create drift. + +## Metadata Truth Table + +One of the biggest ways this migration can go wrong is promising metadata that the backend does not actually know reliably. + +### Metadata that already exists today in authored source + +From current authored plugin source, `plugin-kit-ai` can already obtain: + +- `integration_id` +- `version` +- `description` +- `homepage` +- `repository` +- `license` +- `keywords` +- declared `targets` + +### Important caveat + +The current `integrationctl` manifest loader does not carry all of those fields forward today. + +So there are two safe paths: + +1. extend `integrationctl` manifest loading to preserve richer metadata +2. build `catalog` from the richer authored-model path and translate it into the catalog contract + +What must not happen: + +- the app inventing those fields +- the app scraping raw repo files directly +- two different backend paths returning contradictory storefront metadata + +### Metadata the backend can derive safely + +The backend can also derive: + +- generated `delivery_kinds` +- `available_targets` +- `supported_scopes_by_target` from target adapter capabilities +- `readme` location using a stable default rule +- provenance fields such as source ref, revision, manifest digest, generated-by version + +### Metadata that is not a safe phase-1 assumption + +These fields are optional curation data, not phase-1 requirements: + +- `category` +- `icon_url` +- `install_count` +- `popularity` +- `featured_rank` + +### Recommended default + +Phase 1 should require only: + +- name +- description +- version +- homepage / repository +- keywords +- target support +- scope support +- README/detail + +If `category`, `icon`, or popularity are absent, the UI should hide or degrade those features honestly. + +## Storefront Detail and README Semantics + +The plugin detail surface must use the authored human guide, not arbitrary generated root docs. + +### Current code reality + +`plugin-kit-ai` already generates root-facing docs such as: + +- `README.md` +- `GENERATED.md` +- boundary guidance docs + +But those are operational/generated root entrypoints. +They are not the best source of storefront detail content. + +Current code also makes the authored README explicit: + +- the managed root `README.md` points readers back to `plugin/README.md` +- generated docs inventory also treats root docs differently from managed outputs + +That is a strong signal that the authored README remains the source of truth for human-facing plugin detail. + +### Recommended detail rule + +For universal catalog entries, the default detail source should be: + +- authored `plugin/README.md` + +Not: + +- generated root `README.md` +- `GENERATED.md` +- boundary docs like `AGENTS.md` + +### Why this matters + +If the app accidentally uses generated root docs as storefront detail: + +- the detail view becomes noisy and operational +- it may emphasize generate/normalize workflows instead of plugin value +- the same plugin can appear to have unstable detail content depending on packaging mode + +### Recommended contract shape + +The catalog contract should prefer an explicit detail reference such as: + +- `detail_kind: "authored_readme"` +- `detail_ref` + +Recommended phase-1 default: + +- `detail_kind = "authored_readme"` +- `detail_ref` points to the authored README location within the catalog source snapshot + +### Recommended app rule + +- plugin cards use catalog summary fields +- plugin detail loads the authored detail reference when available +- if detail content is missing, the app should degrade honestly instead of substituting generated operational docs + +## Catalog Field Ownership Matrix + +This makes the contract much easier to implement because it is explicit about where each field should come from. + +| Catalog field | Recommended source in `plugin-kit-ai` | Notes | +|---|---|---| +| `integration_id` | authored manifest `name` | stable universal identity | +| `display_name` | authored manifest `name` initially | future curation can improve presentation | +| `description` | authored manifest `description` | required | +| `version` | authored manifest `version` | required | +| `homepage_url` | richer authored model | not present in current narrow lifecycle manifest | +| `repository_url` | richer authored model | not present in current narrow lifecycle manifest | +| `keywords` | richer authored model | phase-1 safe metadata | +| `authored_targets` | authored manifest `targets` | preserve exact authored truth | +| `manageable_targets` | lifecycle target mapping / registered adapters | what backend can actually act on | +| `primary_action_targets` | app rollout policy | narrower than full backend target universe | +| `available_app_targets` | backend target projection | provider-facing labels for `claude_team` | +| `supported_scopes_by_target` | target adapter capabilities | backend-owned truth | +| `capabilities` | delivery mapping and/or target contract data | do not invent in app | +| `readme_url` | catalog translation layer | use a stable default rule | +| `category` | optional curation metadata | do not block phase 1 | +| `icon_url` | optional curation metadata | do not block phase 1 | +| `catalog_revision` | catalog generation pipeline | provenance | +| `generated_by_plugin_kit_version` | CLI/backend build info | provenance | + +### Important rule + +If a field does not have a trustworthy backend source yet, phase 1 should omit or degrade it instead of synthesizing it in the app. + +## Effective Metadata Projection + +Catalog generation must distinguish between: + +- shared plugin metadata +- target-specific effective metadata + +This matters because current `plugin-kit-ai` code already allows target-specific metadata overlays, especially for package-style targets such as `codex-package`. + +### Shared metadata + +Shared metadata should come from the authored manifest layer: + +- `name` +- `version` +- `description` +- base `homepage` +- base `repository` +- base `license` +- base `keywords` +- base `author` + +This is the safest metadata for: + +- mixed-target storefront cards +- cross-target search +- universal identity + +### Target-specific effective metadata + +For some targets, especially `codex-package`, the effective generated package metadata is: + +- base manifest metadata +- plus allowed target-specific overrides from `targets//package.yaml` + +Current code already proves this path exists: + +- `codex-package` generation merges base manifest metadata with optional `targets/codex-package/package.yaml` +- validation checks the generated Codex package metadata against that merged expectation + +### Recommended catalog rule + +The catalog contract should preserve both layers explicitly: + +- shared metadata for the universal entry itself +- optional `effective_target_metadata` for targets that project different package metadata + +Example shape: + +- `shared_metadata` +- `effective_target_metadata.codex` + +At minimum, per-target effective metadata may include: + +- `homepage` +- `repository` +- `license` +- `keywords` +- `author` + +### Recommended UI rule + +- list cards should use shared metadata +- provider-specific effective metadata should appear only in target detail sections or provider-specific support details +- the app must not silently replace universal card metadata with one target's override + +Why: + +- otherwise a `codex-package` override could accidentally become the visible truth for a plugin that is still conceptually universal +- that would make shared cards unstable and misleading across providers + +### Recommended phase-1 default + +If effective target metadata is not yet emitted in the contract: + +- use shared metadata only +- do not guess target-specific homepage/repository/license in the app +- add effective target metadata later as an additive contract field + +### Conservative phase-1 metadata rule + +For phase 1, treat target-specific effective metadata as enhancement, not as a dependency. + +That means: + +- search, ranking, and primary cards use shared metadata only +- target-specific metadata appears only when backend emits it explicitly +- absence of effective target metadata must never block installability rendering +- the app must not read target-specific docs or package manifests directly to reconstruct this layer + +## Native Discovery Model + +`discover` is a genuinely new backend surface. +It must not be implemented as a thin wrapper over current `List` or current per-target `Inspect`. + +### Why current per-target `Inspect` is not a safe discovery backend + +This needs to be explicit because reusing current adapter `Inspect` can look tempting, but it is the wrong abstraction for external discovery. + +#### Claude + +Current Claude inspect logic is still strongly lifecycle-oriented: + +- it resolves inspect identity from: + - `in.IntegrationID` + - or `in.Record.IntegrationID` +- native plugin-list confirmation then looks for a specific plugin ref: + - defaulting to `integration_id@integrationctl-` + - or a managed plugin ref from record metadata +- if that specific confirmation path does not resolve, current inspect can still fall back to `installed` when native files or CLI availability make the managed candidate look plausible + +That is appropriate for managed lifecycle verification. +It is not appropriate for general native external discovery, because external installs may use: + +- a different marketplace name +- a different plugin ref +- or no managed lifecycle record at all + +#### Codex + +Current Codex inspect logic is also lifecycle-oriented: + +- inspect inputs derive `integration_id` from the managed record +- scope/path construction depends on that managed integration identity +- state classification assumes it is inspecting a known candidate plugin root +- current observed-surface logic is built around a specific expected catalog path, plugin root, and config path +- current lifecycle classification then reasons from managed cache presence plus that expected surface bundle + +That is useful for verifying a managed installation. +It is not enough for general native external enumeration, which first needs to discover candidates before it can classify them. + +### Recommended backend rule + +- `discover` must be its own scanner-oriented surface +- it may reuse helper functions from adapters where useful +- but it must not simply loop over current adapter `Inspect` without an independent candidate-enumeration layer + +### Critical discovery anti-patterns + +These are explicitly forbidden: + +1. calling current adapter `Inspect` on arbitrary filesystem hits and treating the result as native discovery truth +2. suppressing a discovered entry before comparing it against managed lifecycle evidence +3. upgrading a heuristic name match into an exact overlap suppression signal +4. deriving native manageability from UI assumptions instead of backend evidence +5. hiding a discovered entry only because catalog lookup failed or was stale + +If implementation pressure pushes toward any of these shortcuts, the correct fix is to extend backend discovery evidence, not to make the app smarter. + +### Practical implementation shape + +Recommended backend structure: + +1. enumerate native candidates from target-specific sources +2. derive native identity and evidence for each candidate +3. suppress candidates already explained by managed lifecycle state +4. classify observed state +5. compute advisory relation to universal catalog +6. emit normalized discovery entries + +This keeps `discover` honest: + +- enumeration first +- classification second +- matching last +- read-only throughout + +### Claude discovery sources + +- `~/.claude/plugins/installed_plugins.json` +- `~/.claude/settings.json` +- `/.claude/settings.json` +- `/.claude/settings.local.json` + +### Codex discovery sources + +- `~/.agents/plugins/marketplace.json` +- `/.agents/plugins/marketplace.json` +- `~/.agents/plugins/plugins/` +- `/.agents/plugins/plugins/` +- `~/.codex/plugins/cache///local` +- `~/.codex/config.toml` + +### Required observed states + +- `observed_active` +- `observed_disabled` +- `observed_prepared` +- `observed_degraded` + +### Recommended observed-state derivation rules + +Observed-state classification should be target-specific and evidence-driven. + +#### Codex + +Current adapter code already implies a practical state ladder: + +- `observed_active` + - cache bundle exists + - and marketplace catalog + plugin root are present + - and config does not mark the plugin disabled +- `observed_disabled` + - cache bundle exists + - and config toggle is present and disabled +- `observed_prepared` + - marketplace entry exists and plugin root exists + - but activation evidence such as cache bundle is not present yet +- `observed_degraded` + - only part of the expected prepared/install surface exists + - or cache exists while managed marketplace source is missing or drifted + +Important rule: + +- `discover` should keep this richer observed-state truth +- the app should not collapse everything into plain `installed/not installed` + +### Codex evidence mapping table + +The first implementation should stay conservative and evidence-driven. + +| Evidence seen by discovery | Recommended observed state | Why | +|---|---|---| +| marketplace entry + plugin root + installed cache, config not disabled | `observed_active` | strongest “prepared and activated” signal available today | +| installed cache + config toggle present and disabled | `observed_disabled` | disable state is explicit | +| marketplace entry + plugin root, but no installed cache yet | `observed_prepared` | package is staged but native activation is not complete | +| only one of marketplace entry or plugin root exists | `observed_degraded` | partial native surface | +| installed cache exists but marketplace entry or plugin root is missing | `observed_degraded` | drifted or partially removed managed/native surface | +| config references plugin, but marketplace entry and plugin root are both absent | `observed_degraded` | stale toggle or orphaned config | + +### Conservative phase-1 defaults for Codex discovery + +Until discovery evidence is richer, prefer these defaults: + +- if evidence is ambiguous, downgrade to `observed_degraded` +- do not claim `observed_active` from config evidence alone +- do not infer exact universal matching from marketplace entry name alone +- do not infer safe removal from discovered Codex paths alone +- do not suppress a discovered Codex entry unless managed-overlap evidence includes owned objects or stable lifecycle evidence + +#### Claude + +For phase 1, Claude discovery may stay simpler: + +- `observed_active` + - plugin appears in native plugin list and is enabled +- `observed_disabled` + - plugin appears in native plugin list and is disabled +- `observed_degraded` + - settings or install evidence exists but plugin list cannot confirm a clean state +- `observed_prepared` + - optional future state only if backend gains a meaningful pre-install or staged marketplace concept for external Claude installs + +Recommended rule: + +- do not force artificial state parity between Claude and Codex +- preserve richer Codex states where the backend can actually justify them + +### Required extra fields + +- `native_target` +- `native_plugin_id` +- `installed_scopes` +- `detected_source` +- `manageability` +- `matched_integration_id` +- `match_confidence` +- `match_basis` +- `identity_evidence` +- `activation_hint` + +### Discovery manageability rule + +The backend must declare whether a native external entry is: + +- `display_only` +- `safe_remove` +- `safe_adopt` +- or another explicit future mode + +The app must not infer destructive authority. + +### Discovery overlap suppression rule + +`discover` must not blindly report every observed native install as `native_external_installed`. + +Why this is necessary: + +- managed universal installs also materialize into native agent surfaces +- a naive scanner would rediscover those same installs and duplicate them as native external entries + +Recommended backend rule: + +- `discover` should load managed lifecycle state, or equivalent managed evidence, before finalizing external entries +- if an observed native install is already explained by managed lifecycle state with high confidence, it should either: + - be suppressed from discovery output, or + - be explicitly marked as managed overlap for app-side filtering + +Recommended default: + +- suppress managed-overlap entries in the discovery payload +- discovery should describe only installs that are not already explained by managed lifecycle state + +### Managed-overlap evidence examples + +Claude examples from current code: + +- synthetic marketplace name pattern `integrationctl-` +- managed plugin ref recorded in lifecycle metadata +- managed materialized marketplace root under `~/.plugin-kit-ai/materialized/claude/` + +Codex examples from current code: + +- managed plugin root under `.agents/plugins/plugins/` +- managed catalog entry name equal to `integration_id` together with lifecycle-owned native objects +- managed config ref `@` when that catalog name is already tied to a managed installation + +Important rule: + +- suppression should prefer owned native object evidence and managed lifecycle state over name heuristics +- name equality alone is not enough to classify something as managed overlap + +## Managed Lifecycle Model + +### Existing lifecycle surfaces + +- `list` for managed installations +- `doctor` for managed drift / activation / auth attention +- `add`, `update`, `remove`, `repair` for mutations + +### Important current gap + +Current `Report.Targets` do not identify which integration a target belongs to. + +The app needs lifecycle JSON to include integration-level context such as: + +- `integration_id` +- `managed_entry_key` +- source refs +- policy scope +- workspace root + +### Why the current raw lifecycle report is not enough for app integration + +Today the raw `integrationctl` lifecycle query shape is still too flat for the plugin page. + +In current code: + +- `domain.Report` contains only: + - `summary` + - `targets` + - `warnings` +- `domain.TargetReport` contains per-target state such as: + - `target` + - `delivery_kind` + - `state` + - `activation_state` + - `environment_restrictions` + - `manual_steps` + +What it does **not** preserve at the same level: + +- `integration_id` +- `requested_source_ref` +- `resolved_source_ref` +- `resolved_version` +- `policy.scope` +- `workspace_root` +- a stable grouping boundary between one integration and another + +That matters because the app needs to render cards and detail views at the integration-entry level, not as an ungrouped stream of target facts. + +Recommended rule: + +- `plugin-kit-ai` should keep its current internal normalized lifecycle model +- but the app-facing JSON contract must expose managed entries grouped by integration +- `claude_team` must not try to reconstruct integration grouping from flat target rows by heuristics + +### Required grouped lifecycle identifiers + +For app-facing lifecycle JSON, each managed entry should include at minimum: + +- `managed_entry_key` +- `integration_id` +- `requested_source_ref` +- `resolved_source_ref` +- `resolved_version` +- `policy.scope` +- `workspace_root` + +Recommended rule: + +- `managed_entry_key` should be stable for one stored installation record +- it should not depend on target row order +- it should be safe for the app to use as the primary cache and merge key for managed lifecycle entries + +Without this, the frontend will eventually drift into reconstructing groups from target arrays, which is fragile and unnecessary. + +### Conservative phase-1 grouped lifecycle rule + +Until the backend exposes a more formal record identifier, phase 1 should still require: + +- one grouped managed entry per stored installation record +- stable ordering of `managed_entries` +- stable ordering of nested `targets` +- explicit grouping keys in payload, not implied grouping by adjacent rows + +The app must treat missing grouping keys as a compatibility problem, not as an invitation to reconstruct them heuristically. + +### Critical CLI semantic to freeze + +Current `integrations` mutating commands default to `--dry-run=true`. + +That is good for humans in a terminal, but dangerous for app integration. + +Recommended rule: + +- machine-readable mutating calls from `claude_team` must always pass explicit execution mode +- either: + - `--dry-run=false`, or + - a future clearer flag such as `--apply` + +The app must never rely on CLI defaults for mutating behavior. + +## JSON Contract Style + +`plugin-kit-ai` already has a public JSON contract style in surfaces like `validate` and `publication`. +The new integrations contracts should follow that style instead of inventing a second JSON dialect. + +### Required envelope rules + +- top-level `format` +- top-level `schema_version` +- explicit request context fields where relevant +- top-level `warning_count` +- top-level `warnings` +- one canonical payload field rather than many competing summary shapes + +### Requested context rule + +Current public JSON reports in `plugin-kit-ai` already use request-context fields such as: + +- `requested_target` +- `requested_platform` + +Recommended rule for the integrations surfaces: + +- include explicit request-context fields when the command accepts them +- examples: + - `requested_targets` + - `requested_scope` + - `requested_workspace_root` + - `requested_integration_id` + +This makes automation and debugging much safer than inferring invocation context from payload shape. + +### Required array guarantees + +In schema version `1`, the following fields should be arrays, never `null`: + +- `warnings` +- `entries` +- `managed_entries` +- `targets` + +### Compatibility rules + +- additive fields are allowed within the same `schema_version` +- semantic changes to existing fields require a new `schema_version` +- removing a field the app depends on requires a new `schema_version` +- enum meaning changes require a new `schema_version` + +### App behavior on unsupported versions + +If the backend returns a newer unsupported schema: + +- read-only views may continue only if safe +- lifecycle actions must be disabled +- the UI must explain the compatibility mismatch clearly + +### Process exit and payload semantics + +This must be explicit because current `plugin-kit-ai` already has public JSON commands that can: + +- print a valid JSON payload to stdout +- then still exit non-zero because the payload describes a failing or issue-bearing report + +Current examples in code: + +- `validate --format json` +- `publication doctor --format json` + +Recommended rule for the integrations surfaces: + +- stdout JSON is the canonical machine-readable payload +- process exit code is still meaningful, but it must not be the only signal the app uses +- `claude_team` should: + - first attempt to parse a valid JSON payload from stdout + - then interpret payload-level fields such as `outcome`, `ok`, `warning_count`, `failure_count`, `issue_count` + - only fall back to process-exit-only handling when no valid contract payload exists + +Without this rule, the app will misclassify structured partial failures as transport failures. + +### Recommended outcome semantics + +For machine-readable integrations surfaces, outcome should be explicit in the payload instead of inferred only from process exit: + +- read-only reports: + - may expose `ok`, `warning_count`, and optional `issue_count` +- mutating results: + - should expose explicit `outcome` + - recommended values: + - `planned` + - `applied` + - `partial_success` + - `failed` + +The exact enum may still evolve, but the contract must keep payload-level outcome explicit. + +### Recommended identifiers + +- `plugin-kit-ai/integrations-report` +- `plugin-kit-ai/integrations-result` +- `plugin-kit-ai/integrations-catalog` +- `plugin-kit-ai/integrations-discovery` + +## Recommended Contract Drafts + +These drafts are intentionally close to the current `integrationctl` domain model. +They should extend the existing normalized result shape, not invent a second unrelated response model. + +### Managed lifecycle list + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "managed_list", + "requested_targets": [], + "warning_count": 0, + "warnings": [], + "summary": "1 managed integration(s) in state.", + "managed_entries": [ + { + "managed_entry_key": "project:/repo:context7", + "integration_id": "context7", + "requested_source_ref": { + "kind": "github_repo_path", + "value": "github:777genius/universal-plugins-for-ai-agents//plugins/context7" + }, + "resolved_source_ref": { + "kind": "git_commit", + "value": "https://github.com/777genius/universal-plugins-for-ai-agents@abc123" + }, + "resolved_version": "0.1.0", + "workspace_root": "/repo", + "policy": { + "scope": "project", + "auto_update": true, + "adopt_new_targets": "manual" + }, + "targets": [ + { + "target_id": "claude", + "delivery_kind": "claude-marketplace-plugin", + "capability_surface": ["mcp"], + "state": "installed", + "activation_state": "reload_pending", + "source_access_state": "ok" + } + ] + } + ] +} +``` + +### Universal catalog + +```json +{ + "format": "plugin-kit-ai/integrations-catalog", + "schema_version": 1, + "requested_targets": ["claude", "codex"], + "warning_count": 0, + "warnings": [], + "source": { + "kind": "bundled_snapshot", + "fetched_at": "2026-04-18T12:00:00Z", + "revision": "abc123", + "stale": false + }, + "entries": [ + { + "entry_kind": "universal_catalog", + "integration_id": "context7", + "display_name": "Context7", + "description": "Shared MCP plugin for documentation lookup.", + "authored_targets": ["claude", "codex-package"], + "manageable_targets": ["claude", "codex-package"], + "primary_action_targets": ["claude", "codex-package"], + "available_app_targets": ["claude", "codex"], + "keywords": ["mcp", "docs"], + "category": null, + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "readme_url": "https://raw.githubusercontent.com/777genius/universal-plugins-for-ai-agents/main/plugins/context7/plugin/README.md", + "version": "0.1.0", + "effective_target_metadata": { + "codex": { + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "keywords": ["mcp", "docs"] + } + }, + "supported_scopes_by_target": { + "claude": ["user", "project"], + "codex": ["user", "project"] + }, + "capabilities": ["mcp"], + "source_ref": "github:777genius/universal-plugins-for-ai-agents//plugins/context7", + "catalog_revision": "abc123", + "generated_by_plugin_kit_version": "0.0.0" + } + ] +} +``` + +### Native discovery + +```json +{ + "format": "plugin-kit-ai/integrations-discovery", + "schema_version": 1, + "requested_targets": ["claude", "codex"], + "requested_workspace_root": "/repo", + "warning_count": 0, + "warnings": [], + "entries": [ + { + "entry_kind": "native_external_installed", + "native_target": "claude", + "native_plugin_id": "context7@claude-plugins-official", + "display_name": "Context7", + "description": "Installed from Claude marketplace.", + "installed_scopes": ["user"], + "detected_source": "claude_marketplace", + "manageability": "display_only", + "matched_integration_id": "context7", + "match_confidence": "exact", + "match_basis": "same_marketplace_identity", + "identity_evidence": [ + "native_plugin_ref=context7@official-marketplace", + "marketplace_name=official-marketplace" + ], + "observed_state": "observed_active", + "activation_hint": "none" + } + ] +} +``` + +### Mutating lifecycle result + +```json +{ + "format": "plugin-kit-ai/integrations-result", + "schema_version": 1, + "requested_integration_id": "context7", + "requested_targets": ["claude", "codex"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": false, + "warning_count": 0, + "warnings": [], + "outcome": "partial_success", + "report": { + "operation_id": "add-context7-...", + "summary": "Managed targets processed for integration \"context7\".", + "integration_id": "context7", + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "state": "installed", + "activation_state": "reload_pending", + "environment_restrictions": ["reload_required"], + "manual_steps": ["reload Claude plugins"] + }, + { + "target_id": "codex", + "action_class": "install_target", + "state": "activation_pending", + "activation_state": "restart_pending", + "environment_restrictions": ["native_activation_required", "new_thread_required"], + "manual_steps": ["restart Codex", "open a new thread"] + } + ] + } +} +``` + +## Entry Derivation and Conflict Resolution + +This is the most important renderer rule-set in the whole integration. + +The app must derive the plugin list deterministically from four backend surfaces: + +- `catalog` +- `discover` +- `list` +- `doctor` + +It must **not** invent entries or merge classes by guesswork. + +### Backend surface ownership + +Each backend surface owns a different truth: + +- `catalog` + - universal storefront truth + - metadata for universal plugins + - target support projection + - provider-facing installability projection +- `list` + - managed universal installed truth + - policy scope + - workspace root + - resolved source + - installed targets +- `doctor` + - managed universal health augmentation + - degraded/auth/activation attention + - target-level restrictions and manual steps +- `discover` + - native external installed truth + - observed scopes + - detected source + - explicit manageability + - optional relation hints to universal entries + +### Entry derivation algorithm + +Recommended deterministic algorithm: + +1. Load `list` and build a managed map keyed by `integration_id`. +2. Overlay `doctor` onto that managed map by `integration_id + target_id`. +3. Load `catalog` and build a universal catalog map keyed by `integration_id`. +4. For every managed entry: + - create one `universal_installed` entry + - enrich it from matching catalog metadata when available + - keep lifecycle-owned fields from `list/doctor` +5. For every catalog entry without a managed match: + - create one `universal_available` entry +6. For every discovery entry: + - create one `native_external_installed` entry + - keep it separate even if it matches a universal integration +7. Attach relation metadata between `native_external_installed` and universal entries only as advisory linkage, never as a merge. +8. Sort using the installed-first ranking rules already defined in this plan. + +### Non-negotiable derivation invariants + +- `doctor` may augment managed entries, but must never create standalone entries +- `catalog` may create only universal entries +- `discover` may create only native external entries +- `catalog` must never mark an entry as installed +- `discover` must never mark an entry as managed +- advisory matching must never change `entry_kind` +- if two surfaces disagree, the surface that owns that truth wins + +### Field precedence rules + +For `universal_installed` entries: + +- identity: + - from `list` +- installed state: + - `doctor` if present + - otherwise `list` +- lifecycle actions: + - from lifecycle capabilities and current runtime support +- scope and workspace root: + - from `list` +- display metadata: + - `catalog` first + - lifecycle fallback only when catalog is missing + +For `universal_available` entries: + +- identity and metadata: + - from `catalog` +- supported targets and scopes: + - from `catalog` +- installed state: + - none + +For `native_external_installed` entries: + +- identity: + - from `discover` +- observed state and scopes: + - from `discover` +- manageability: + - from `discover` +- relation to universal entries: + - advisory only + +### Conflict resolution rules + +If `catalog` says a universal plugin exists, but `list` has no managed installation: + +- render `universal_available` + +If `list` has a managed installation, but `catalog` entry is missing because the catalog snapshot is stale or incomplete: + +- still render `universal_installed` +- mark catalog metadata as unavailable +- do not hide the installed entry + +If `discover` finds a native external install that strongly matches a universal plugin: + +- show both entries +- add relation hints such as `Also available as universal plugin` +- do not collapse them into one card + +If `doctor` returns a degraded target while `list` shows the same target as installed: + +- `doctor` wins for health/status presentation +- `list` remains the source of integration ownership and policy context + +### Renderer field ownership + +Recommended renderer ownership matrix: + +- `entry_kind` + - derived by the app from surface class, never from heuristics +- `integration_id` + - `catalog` or `list` + - never guessed from discovery display name alone +- `display_name` + - `catalog` for universal entries + - `discover` for native external entries +- `description` + - `catalog` for universal entries + - `discover` for native external entries +- `supported_targets` + - `catalog` +- `manageable_targets` + - `catalog` +- `primary_action_targets` + - `catalog` +- `installed_targets` + - `list`, augmented by `doctor` +- `health_state` + - `doctor` +- `observed_scopes` + - `discover` +- `manageability` + - `discover` +- `resolved_source_ref` + - `list` +- `workspace_root` + - `list` + +### Why this section matters + +Without these derivation rules, the app will almost certainly drift into one of the following failure modes: + +- silently merging universal and native external installs +- showing `available` when something is already installed +- losing managed entries when the catalog snapshot is stale +- inventing manageability for discovered native installs +- letting `catalog` or `discover` override lifecycle truth they do not own + +## Worked Examples + +These examples are intentionally concrete. +They should be used as golden fixtures for both backend contracts and app normalization. + +### Example 1 - managed universal install with overlap suppression + +Situation: + +- `catalog` contains universal `context7` +- `list` contains managed installation `context7` +- `doctor` says target is healthy +- native surfaces also visibly contain the installed plugin because managed lifecycle already materialized it there + +Expected result: + +- render one `universal_installed` entry for `context7` +- do **not** render a second `native_external_installed` copy for the same managed install +- health comes from `doctor` +- metadata comes from `catalog` + +Why: + +- discovery overlap suppression should remove the duplicate native observation + +### Example 2 - native Claude marketplace install with no managed lifecycle record + +Situation: + +- `catalog` contains universal `context7` +- `list` does not contain `context7` +- `discover` finds a Claude-native install from a marketplace source +- backend can only establish an advisory relation to universal `context7` + +Expected result: + +- render one `native_external_installed` entry +- optionally render the universal `context7` catalog card separately if not installed through `plugin-kit-ai` +- show relation hint such as `Also available as universal plugin` +- do not collapse them into one entry +- do not show destructive managed actions unless discovery explicitly says they are safe + +### Example 3 - stale or partial catalog snapshot + +Situation: + +- `list` contains managed `demo-plugin` +- `doctor` contains managed `demo-plugin` +- `catalog` snapshot does not contain `demo-plugin` + +Expected result: + +- still render `demo-plugin` as `universal_installed` +- preserve lifecycle actions and managed state +- degrade catalog-derived metadata gracefully +- do not hide the entry just because storefront metadata is missing + +Why: + +- managed installation truth belongs to `list` +- catalog absence is not permission to erase installed truth + +### Example 4 - Codex prepared but not activated yet + +Situation: + +- `discover` finds: + - marketplace entry + - plugin root + - no cache bundle yet +- backend classifies the Codex plugin as prepared but not active + +Expected result: + +- render `native_external_installed` +- show observed state `prepared` +- do not claim it is fully active +- if the backend has an activation hint, show it +- do not pretend this equals a healthy managed universal install + +### Example 5 - degraded Codex native install + +Situation: + +- cache bundle exists +- but expected marketplace source or plugin root is missing or drifted + +Expected result: + +- render `native_external_installed` +- show observed state `degraded` +- do not auto-remove or auto-adopt +- keep relation to universal entries advisory only + +### Example 6 - heuristic match only + +Situation: + +- `discover` finds native plugin display name `Notes` +- `catalog` contains more than one plausible universal candidate with similar wording + +Expected result: + +- either no match or `heuristic` +- no merge +- no stronger action unlock +- no `exact` badge or stronger “same plugin” copy + +## Command Semantics Matrix + +The app integration should treat command classes differently. + +| Surface | Command class | Side effects | App expectation | +|---|---|---|---| +| `catalog` | read-only | none | safe to retry, safe to cache | +| `discover` | read-only | none | safe to retry, safe to cache | +| `list` | read-only | none | safe to retry, source of managed ownership | +| `doctor` | read-only | none | may report issues and still return structured JSON | +| `add` | mutating | yes | must pass explicit apply mode and explicit context | +| `update` | mutating | yes | must pass explicit apply mode | +| `remove` | mutating | yes | must pass explicit apply mode | +| `repair` | mutating | yes | must pass explicit apply mode | + +### Read-only command rules + +- read-only commands must never mutate native state +- read-only commands may return structured issues without that meaning a transport failure +- the app may cache read-only payloads +- the app may retry read-only commands automatically + +### Mutating command rules + +- mutating commands must never rely on CLI defaults for apply behavior +- mutating commands must always receive explicit context: + - target selection where relevant + - scope where relevant + - workspace root where relevant +- post-mutation refresh in the app must use the origin operation context, not global last-view state + +### Payload vs process-failure rule + +For app integration there are two different failure classes: + +- transport/process failure + - no valid contract payload + - process spawn failure + - timeout + - malformed JSON +- structured domain failure + - valid contract payload exists + - payload says `ok=false`, `outcome=failed`, `issue_count>0`, or equivalent + +The app must distinguish those two classes clearly. + +### Why this section matters + +Without a command semantics matrix, the app will eventually do one or more of these: + +- treat every non-zero exit as an unstructured crash +- lose useful report payloads on domain failures +- accidentally rely on `--dry-run=true` +- retry mutating commands as if they were read-only + +## UI and Entry Model in claude_team + +### Normalized entry kinds + +- `universal_available` +- `universal_installed` +- `native_external_installed` + +### Ranking order + +1. installed universal +2. installed native external +3. available universal + +### Card labels + +Universal: + +- `Universal` +- `Anthropic + Codex` +- `Anthropic only` +- `Codex only` + +Native external: + +- `Installed - Anthropic only` +- `Installed - Codex only` + +### Detail requirements + +Universal detail must show: + +- keywords or tags +- category only when curated metadata exists +- target support +- scope support +- README/detail content +- lifecycle actions where supported + +Native external detail must show: + +- native target +- detected source +- observed scopes +- manageability +- relation to universal plugin when matched + +### Empty and degraded states + +If `catalog` fails but `discover` works: + +- show native external entries +- show a warning for universal catalog unavailability + +If `discover` fails but `catalog` works: + +- show universal entries +- do not claim “no installed plugins” +- show a warning that native discovery is unavailable + +If backend version is unsupported: + +- keep read-only view if safe +- disable lifecycle actions +- show explicit compatibility message + +## App Integration Mode + +`claude_team` should integrate with `plugin-kit-ai` as a bundled CLI binary with versioned JSON I/O. + +Not as: + +- parsed human terminal output +- direct repo scraping +- an embedded second UI +- a Go library linked into Electron + +Why: + +- Electron already knows how to run bundled binaries +- JSON contracts are versionable and testable +- rollout and rollback stay simple +- the same backend surface can be exercised in dev and packaged builds + +## plugin-kit-ai Changes Required + +### Must-have for phase 0 + +1. `integrations --format json` around the current normalized lifecycle model + 🎯 10 🛡️ 10 🧠 4 + Approximate change size: `150-300` lines + +2. integration-level fields in managed lifecycle JSON + Needed because current `Report.Targets` do not identify which integration a target belongs to. + 🎯 10 🛡️ 10 🧠 5 + Approximate change size: `120-240` lines + +3. `integrations catalog --format json` + 🎯 10 🛡️ 9 🧠 5 + Approximate change size: `180-350` lines + +4. `integrations discover --format json` + 🎯 10 🛡️ 10 🧠 6 + Approximate change size: `220-450` lines + +5. `--workspace-root` + 🎯 9 🛡️ 10 🧠 4 + Approximate change size: `80-180` lines + +6. capability and scope metadata in catalog/discovery + 🎯 9 🛡️ 9 🧠 4 + Approximate change size: `80-160` lines + +7. stable detail path or detail endpoint + 🎯 8 🛡️ 8 🧠 4 + Approximate change size: `60-140` lines + +8. discovery trust/manageability metadata + 🎯 8 🛡️ 9 🧠 5 + Approximate change size: `80-180` lines + +9. provenance metadata + 🎯 8 🛡️ 9 🧠 4 + Approximate change size: `60-140` lines + +### Explicitly not required for the first app rollout + +- `integrations sync` +- workspace-lock driven desired-state workflows +- `enable` / `disable` UI +- native convenience uninstall +- adopt + +Reason: + +- they are either repo-lock oriented, lower-value than core lifecycle, or too risky before `discover` is proven out + +## claude_team Changes Required + +### Main-process additions + +- `PluginKitBinaryResolver` +- `PluginKitService` +- `PluginKitCatalogService` +- `PluginKitDiscoveryService` +- `PluginKitLifecycleService` + +### Current app touchpoints this migration must replace or bypass + +Current plugin flow in `claude_team` is still Claude-marketplace-shaped: + +- `src/main/services/extensions/catalog/PluginCatalogService.ts` +- `src/main/services/extensions/state/PluginInstallationStateService.ts` +- `src/main/services/extensions/install/PluginInstallService.ts` +- `src/main/services/extensions/ExtensionFacadeService.ts` +- `src/shared/types/extensions/plugin.ts` +- `src/renderer/store/slices/extensionsSlice.ts` +- `src/renderer/components/extensions/plugins/*` + +Recommended rule: + +- do not keep stretching the current `EnrichedPlugin` model until it represents two different product classes badly +- introduce a new normalized plugin-entry layer for the plugin-kit-backed flow +- keep the legacy Claude-only model behind the feature flag until rollout is complete + +### Required app-side model split + +Current `EnrichedPlugin` is shaped around one catalog plus installed counts: + +- one canonical `pluginId` +- one marketplace-oriented metadata shape +- one merged installed-state view + +That is not a safe long-term shape for mixed: + +- `universal_catalog` +- `universal_installed` +- `native_external_installed` + +Recommended rule: + +- phase 1 should add a new normalized entry model for the plugin page +- old `EnrichedPlugin` can remain only inside the legacy backend path +- the new renderer/store layer should be built around explicit `entry_kind`, not around legacy marketplace assumptions + +### Responsibilities + +#### `PluginKitBinaryResolver` + +- resolve bundled binary +- resolve dev binary +- report version + +#### `PluginKitService` + +- execute commands +- validate `format` and `schema_version` +- apply timeouts +- normalize errors +- redact diagnostics + +#### `PluginKitCatalogService` + +- call `catalog` +- cache normalized results + +#### `PluginKitDiscoveryService` + +- call `discover` +- normalize native external entries + +#### `PluginKitLifecycleService` + +- call `add/update/remove/repair/list/doctor` +- normalize target-level results + +### Store and cache rules + +- only one mutating operation per `entryId + scope + projectPath` +- `catalog` and `discover` may refresh in parallel +- stale responses must never overwrite newer state +- post-mutation refresh must use the origin operation context + +### Feature flag + +Recommended app flag: + +- `extensions.plugins.backend = legacy | plugin-kit` + +## Rollout Phases + +### Phase 0 - plugin-kit-ai contracts first + +🎯 10 🛡️ 10 🧠 5 +Approximate change size: `400-900` lines + +Ship: + +- JSON envelopes +- `catalog` +- `discover` +- `--workspace-root` +- schema docs +- source/provenance metadata + +Acceptance: + +- contracts are versioned and testable +- command classes are clearly read-only vs mutating +- managed lifecycle JSON includes integration-level context +- views are internally consistent across `catalog`, `discover`, `list`, and `doctor` + +### Phase 1 - read-only app integration + +🎯 9 🛡️ 9 🧠 5 +Approximate change size: `300-650` lines + +Ship: + +- bundled binary +- catalog rendering +- discovery rendering +- mixed-entry normalization +- ranking and labels +- feature-flagged backend switch + +Acceptance: + +- native external entries render truthfully +- universal catalog renders truthfully +- no misleading install button on native external entries +- page remains useful when `catalog` or `discover` partially fail +- warm-load performance remains acceptable +- plugin-kit-backed renderer state uses the new normalized entry model instead of overloading legacy `EnrichedPlugin` + +### Phase 2 - universal lifecycle actions + +🎯 9 🛡️ 9 🧠 6 +Approximate change size: `300-700` lines + +Ship: + +- install +- update +- remove +- repair +- target-level result rendering + +Acceptance: + +- direct Claude path green +- multimodel Anthropic + Codex path green +- user/project installs stable +- partial target results rendered truthfully +- safe retries do not corrupt state + +### Phase 3 - optional native convenience flows + +🎯 7 🛡️ 8 🧠 7 +Approximate change size: `150-400` lines + +Optional: + +- native uninstall where backend declares safe +- adopt where backend declares safe + +Acceptance: + +- no ambiguity between universal managed and native external state + +## Recommended First PR Sequence + +### PR 1 - JSON envelopes for existing lifecycle commands + +Ship: + +- `integrations {list|doctor|add|update|remove|repair} --format json` +- schema identifiers +- contract docs + +Must not do: + +- change lifecycle semantics +- invent `catalog` or `discover` early + +### PR 2 - managed lifecycle integration context + +Ship: + +- `integration_id` +- source refs +- policy scope +- workspace root +- target grouping under managed entries + +Must not do: + +- flatten per-target state into one integration status + +### PR 3 - explicit workspace root control + +Ship: + +- `--workspace-root` +- project-sensitive commands stop depending on implicit `cwd` + +Must not do: + +- silently rewrite missing workspace root into current cwd for project commands + +### PR 4 - universal catalog + +Ship: + +- normalized universal catalog JSON +- bundled snapshot story +- freshness metadata +- README/detail default rule + +Must not do: + +- block on category, icon, or popularity + +### PR 5 - native discovery + +Ship: + +- native external discovery JSON +- observed state +- activation hints +- manageability and match metadata + +Must not do: + +- auto-merge native and universal entries +- expose destructive actions without explicit backend manageability + +### PR 6 - claude_team read-only integration + +Ship: + +- bundled binary +- read-only mixed-entry rendering +- ranking +- degraded-state handling + +Must not do: + +- wire mutating actions before backend contracts are pinned + +## PR Exit Criteria + +These checks are intentionally strict. A PR is not “basically done” if these are still fuzzy. + +### Backend contract PRs + +Required: + +- schema id is documented +- schema version is documented +- arrays are never `null` +- one golden fixture exists +- one failure fixture exists +- one compatibility test exists + +### Backend discovery PRs + +Required: + +- at least one Claude discovery fixture +- at least one Codex discovery fixture +- explicit observed-state coverage +- explicit manageability coverage +- no destructive side effects in read-only commands + +### App read-only integration PRs + +Required: + +- mixed-entry rendering works with only `catalog` +- mixed-entry rendering works with only `discover` +- degraded-state copy is truthful +- unsupported-version handling is visible and safe + +### App lifecycle PRs + +Required: + +- explicit dry-run protection +- target-level partial success rendering +- stale-response protection +- safe retry behavior + +## Risks and Lowest-Confidence Areas + +### 1. Manifest model split between lifecycle and catalog + +🎯 8 🛡️ 8 🧠 6 + +Why this is hard: + +- the richer authored model and the narrower lifecycle model do not preserve the same metadata +- catalog wants richer storefront fields +- lifecycle wants compact normalized install truth + +Best resolution: + +- keep lifecycle and catalog as separate backend surfaces +- allow catalog generation to read the richer authored model +- normalize both into explicit JSON contracts + +### 2. Codex native external discovery fidelity + +🎯 7 🛡️ 8 🧠 7 + +Why this is hard: + +- current Codex inspect logic distinguishes installed, activation pending, disabled, and degraded by combining multiple native surfaces +- that is richer than installed yes/no, but still not a full external discovery surface + +Best resolution: + +- backend-owned discovery +- explicit `observed_state` +- explicit `activation_hint` +- never collapse prepared Codex state into fully active installed state + +### 3. Workspace-root and repo-root coupling + +🎯 8 🛡️ 9 🧠 6 + +Why this is hard: + +- current service construction still derives important paths from `os.Getwd()` +- workspace-lock paths are repo-root oriented +- packaged Electron app must not inherit the wrong working directory semantics + +Best resolution: + +- add explicit `--workspace-root` +- reject missing workspace root for project-sensitive commands +- keep `sync` and workspace-lock flows out of the first app rollout + +### 4. Source resolution cost for lifecycle vs catalog + +🎯 8 🛡️ 9 🧠 5 + +Why this is hard: + +- lifecycle source resolution may legitimately clone remote sources +- storefront catalog must stay fast and bounded + +Best resolution: + +- keep catalog generation batch-oriented +- keep lifecycle resolution source-oriented +- never build the app catalog by doing one source clone per entry at runtime + +### 5. Popularity parity + +🎯 6 🛡️ 9 🧠 5 + +Best resolution: + +- make popularity optional +- add it later only from a first-class universal metric + +### 6. Safe native convenience actions + +🎯 6 🛡️ 8 🧠 7 + +Best resolution: + +- delay to phase 3 +- require backend-declared manageability + +### 7. App-facing target subset confusion + +🎯 8 🛡️ 9 🧠 5 + +Why this is hard: + +- `plugin-kit-ai` knows more targets than the app should expose as first-class plugin lanes +- if the app shows every authored target equally, users will see support the app cannot actually manage yet + +Best resolution: + +- preserve full authored support in backend catalog +- use only `claude` and `codex-package` for primary actionability in `claude_team` +- keep broader support as secondary metadata only + +### 8. Target projection drift between authored, manageable, and app-primary targets + +🎯 8 🛡️ 9 🧠 6 + +Why this is hard: + +- these three target sets are related but not identical +- if they collapse into one field, the UI will eventually lie about support or actionability + +Best resolution: + +- preserve separate target fields in catalog +- test them with golden fixtures +- never let renderer heuristics rebuild one layer from another + +## E2E and Contract Testing + +### plugin-kit-ai + +Must add: + +- JSON contract tests for `catalog`, `discover`, `list`, `doctor` +- JSON contract tests for `add/update/remove/repair` +- temp-home tests +- temp-project tests +- target adapter tests for Claude and Codex +- golden fixture tests against at least one real universal plugin + +### claude_team + +Must add: + +- `PluginKitService` parsing tests +- mixed-entry normalization tests +- ranking tests +- badge tests +- manual-step rendering tests +- project-context tests + +### Failure checks + +- bundled binary missing +- bundled binary wrong architecture +- schema mismatch +- project install without workspace root +- unsupported scope for selected target +- partial target success +- stale catalog with failed refresh +- repeated idempotent retries + +## No-Go Conditions + +Do not enable by default if any of these remain true: + +- universal and native external entries still auto-merge by display name +- lifecycle still depends on parsing prose +- `local` scope is silently remapped +- partial target failures are flattened into one misleading status +- native external entries expose destructive actions without backend-declared manageability + +## Open Questions With Recommended Defaults + +### 1. Should the app call GitHub directly for universal plugins? + +Recommended default: **No**. + +Use backend `catalog`. + +### 2. Should native external entries merge into universal entries when matching looks obvious? + +Recommended default: **No**. + +Keep separate and use soft relations only. + +### 3. Should `local` scope be shown for universal installs in phase 1? + +Recommended default: **No**. + +Only show scopes explicitly supported by backend target metadata. + +### 4. Should popularity sorting block the migration? + +Recommended default: **No**. + +Hide or degrade it if there is no stable universal metric. + +### 5. Should `adopt` block phase 1? + +Recommended default: **No**. + +Phase 1 needs `discover`, not `adopt`. + +### 6. Should native uninstall for discovered entries ship immediately? + +Recommended default: **No**. + +Only where backend declares safe manageability. + +### 7. Should category or icon metadata block phase 1? + +Recommended default: **No**. + +Ship phase 1 with honest minimum storefront metadata, then add optional curation metadata later. + +### 8. Should `discover` be implemented by reusing current per-target `Inspect` directly? + +Recommended default: **No**. + +Use `Inspect` as one building block, but build a real native discovery scanner above it because external discovery and managed inspection are not the same problem. + +### 9. Should catalog be generated from the current `integrationctl` manifest loader only? + +Recommended default: **No**. + +Prefer the richer authored-model path such as `pluginmanifest.Inspect/publication/targetcontracts`, or enrich the lifecycle loader first. + +### 10. Should the `claude_team` plugin page expose every target known to `plugin-kit-ai` as a first-class action lane? + +Recommended default: **No**. + +Use the app-relevant target subset for primary actions: + +- `claude` +- `codex-package` + +Keep broader authored support as optional secondary detail, not as primary actionability. + +### 11. Should the catalog expose only one target field? + +Recommended default: **No**. + +Keep separate fields for: + +- authored target support +- backend-manageable lifecycle support +- app-primary action targets + +## Final Recommendation + +Build this integration in three layers: + +1. make `plugin-kit-ai` expose stable JSON contracts +2. make `plugin-kit-ai` the normalized backend for universal catalog, discovery, and lifecycle +3. make `claude_team` a frontend over those contracts + +This is the most reliable path because it: + +- keeps the current app UX +- reuses the existing lifecycle engine in `plugin-kit-ai` +- avoids false parity and false merging +- keeps rollout reversible diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index de860219..14dcc6c3 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -12,6 +12,7 @@ import { getNonEmptyTaskCategories, groupTasksByDate, groupTasksByProject, + NO_PROJECT_KEY, sortTasksByFreshness, } from '@renderer/utils/taskGrouping'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; @@ -430,8 +431,18 @@ export const GlobalTaskList = ({ ? categories.length > 0 : projectGroups.some((g) => g.tasks.length > 0)); + const noProjectGroupColor = useMemo( + () => ({ + border: 'var(--color-border)', + glow: 'transparent', + icon: 'var(--color-text-muted)', + text: 'var(--color-text-secondary)', + }), + [] + ); + return ( -
+
{!hideHeader && (
{/* Content */} -
+
{globalTasksLoading && !globalTasksInitialized && (
{[1, 2, 3].map((i) => ( @@ -644,7 +655,10 @@ export const GlobalTaskList = ({ projectGroups.map((group) => { if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const groupColor = projectColor(group.projectLabel); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); const visibleCount = getProjectGroupVisibleCount( projectVisibleCountByKey[group.projectKey], group.tasks.length @@ -661,7 +675,9 @@ export const GlobalTaskList = ({ className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors" style={{ backgroundColor: 'var(--color-surface-sidebar)', - backgroundImage: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`, + backgroundImage: isNoProjectGroup + ? undefined + : `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`, boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`, }} > @@ -676,7 +692,7 @@ export const GlobalTaskList = ({ aria-hidden="true" /> {group.projectLabel} diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 3fc87f1c..9c646550 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -51,7 +51,8 @@ export const MemberMessagesTab = ({ const [pagedMessages, setPagedMessages] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(false); + const [initialPageLoading, setInitialPageLoading] = useState(false); + const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); const { readSet } = useTeamMessagesRead(teamName); @@ -74,7 +75,7 @@ export const MemberMessagesTab = ({ setPagedMessages([]); setNextCursor(null); setHasMore(false); - setLoading(true); + setInitialPageLoading(true); void (async () => { try { @@ -95,7 +96,9 @@ export const MemberMessagesTab = ({ setHasMore(false); } } finally { - if (!cancelled) setLoading(false); + if (!cancelled) { + setInitialPageLoading(false); + } } })(); @@ -105,8 +108,8 @@ export const MemberMessagesTab = ({ }, [teamName, memberName]); const loadOlderMessages = useCallback(async () => { - if (!nextCursor || loading) return; - setLoading(true); + if (!nextCursor || loadingOlderMessages) return; + setLoadingOlderMessages(true); try { const page = await api.teams.getMessagesPage(teamName, { beforeTimestamp: nextCursor, @@ -121,9 +124,9 @@ export const MemberMessagesTab = ({ } catch { // best-effort } finally { - setLoading(false); + setLoadingOlderMessages(false); } - }, [teamName, memberName, nextCursor, loading]); + }, [loadingOlderMessages, memberName, nextCursor, teamName]); const effectiveMessages = useMemo( () => mergeTeamMessages(messages, pagedMessages), @@ -198,7 +201,7 @@ export const MemberMessagesTab = ({ [onTaskClick, taskMap, tasks] ); - const emptyStateText = loading + const emptyStateText = initialPageLoading ? 'Loading activity...' : activityFilter === 'comments' ? 'No comments for this member' @@ -289,10 +292,11 @@ export const MemberMessagesTab = ({ variant="ghost" size="sm" className="text-xs" - disabled={loading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {loading ? 'Loading...' : 'Load older messages'} + Load older messages
)} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 5638726c..2b61f147 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -152,13 +152,12 @@ export const MessagesPanel = memo(function MessagesPanel({ const [fetchedMessages, setFetchedMessages] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(false); + const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); const fetchIdRef = useRef(0); // Initial fetch on mount or team change useEffect(() => { const id = ++fetchIdRef.current; - setMessagesLoading(true); void (async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); @@ -171,8 +170,6 @@ export const MessagesPanel = memo(function MessagesPanel({ if (fetchIdRef.current === id && messages.length > 0) { setFetchedMessages(messages); } - } finally { - if (fetchIdRef.current === id) setMessagesLoading(false); } })(); }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change @@ -193,8 +190,8 @@ export const MessagesPanel = memo(function MessagesPanel({ }, [teamName, isTeamAlive, leadActivity]); const loadOlderMessages = useCallback(async () => { - if (!nextCursor || messagesLoading) return; - setMessagesLoading(true); + if (!nextCursor || loadingOlderMessages) return; + setLoadingOlderMessages(true); try { const page = await api.teams.getMessagesPage(teamName, { beforeTimestamp: nextCursor, @@ -206,9 +203,9 @@ export const MessagesPanel = memo(function MessagesPanel({ } catch { // best-effort } finally { - setMessagesLoading(false); + setLoadingOlderMessages(false); } - }, [teamName, nextCursor, messagesLoading]); + }, [loadingOlderMessages, nextCursor, teamName]); // Use fetched messages, fall back to prop messages during initial load const effectiveMessages = useMemo(() => { @@ -307,7 +304,7 @@ export const MessagesPanel = memo(function MessagesPanel({ for (const [element, setHeight] of observedEntries) { if (!element) continue; - const updateHeight = () => { + const updateHeight = (): void => { const nextHeight = Math.ceil(element.getBoundingClientRect().height); if (nextHeight > 0) { setHeight(nextHeight); @@ -684,10 +681,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
)} @@ -869,10 +867,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
)} @@ -1155,10 +1154,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
)} diff --git a/src/renderer/utils/taskGrouping.ts b/src/renderer/utils/taskGrouping.ts index 12f0b2dc..b007bca8 100644 --- a/src/renderer/utils/taskGrouping.ts +++ b/src/renderer/utils/taskGrouping.ts @@ -91,8 +91,8 @@ export function getNonEmptyTaskCategories(groups: DateGroupedTasks): DateCategor return DATE_CATEGORY_ORDER.filter((cat) => groups[cat].length > 0); } -const NO_PROJECT_KEY = '__no_project__'; -const NO_PROJECT_LABEL = 'Without project'; +export const NO_PROJECT_KEY = '__no_project__'; +export const NO_PROJECT_LABEL = 'No project'; function trimTrailingPathSep(p: string): string { let s = p; From ad82cdb9211f6bc881115380653810a2e8fa0a8a Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 14:13:11 +0300 Subject: [PATCH 5/5] docs(extensions): add plugin integration defaults --- .../plugin-kit-ai-integration-plan.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/extensions/plugin-kit-ai-integration-plan.md b/docs/extensions/plugin-kit-ai-integration-plan.md index 1960ea8a..780677f9 100644 --- a/docs/extensions/plugin-kit-ai-integration-plan.md +++ b/docs/extensions/plugin-kit-ai-integration-plan.md @@ -499,6 +499,24 @@ For example: But the first plugin page rollout in `claude_team` should optimize for a clear and reliable main surface, not for exposing the entire backend target universe at once. +### Why packaged support still does not mean first-class app actionability + +Current `platformmeta` already exposes packaged profiles for: + +- `claude` +- `codex-package` +- `codex-runtime` + +That still does **not** mean all three should become first-class action lanes in `claude_team`. + +Recommended rule: + +- packaged/backend support answers “can the backend understand this target family?” +- app-primary actionability answers “should this app expose install/manage actions for this target in the first rollout?” +- those questions are related but not identical + +This is one of the most important places where the plan must stay conservative. + ## Catalog Support Projection Rules Catalog generation should preserve three different truths without collapsing them: @@ -2864,6 +2882,24 @@ Keep separate fields for: - backend-manageable lifecycle support - app-primary action targets +## Phase-1 Conservative Defaults + +These defaults are intentional. +They should not be treated as missing polish or as accidental gaps. + +| Risky seam | Phase-1 default | Why | +|---|---|---| +| `local` scope for universal plugins | hide it | backend does not honestly support it yet | +| native external uninstall | do not expose | destructive authority is not proven yet | +| `adopt` | do not expose | visibility is needed before ownership conversion | +| popularity sorting | optional, not blocking | migration should not depend on a metric the backend does not yet own | +| target-specific metadata | enhance detail only | shared storefront truth should stay stable | +| heuristic universal matching | advisory only | avoids false ownership merge | +| ambiguous Codex observed state | degrade to `observed_degraded` | safer than over-claiming active state | +| stale catalog while managed installs exist | keep managed entry visible | `list` wins for managed existence | +| stale discover while catalog works | keep catalog visible and warn | do not claim “no installed plugins” | +| grouped lifecycle keys missing | treat as incompatible payload | do not reconstruct entry groups in app | + ## Final Recommendation Build this integration in three layers: