feat: add SpecDialog component for editing spec frontmatter

- Implement SpecDialog for managing spec details including title, read mode, priority, and keywords.
- Add validation and keyword management functionality.
- Integrate SpecDialog into SpecsSettingsPage for editing specs.

feat: create index file for specs components

- Export SpecCard, SpecDialog, and related types from a new index file for better organization.

feat: implement SpecsSettingsPage for managing specs and hooks

- Create main settings page with tabs for Project Specs, Personal Specs, Hooks, Injection, and Settings.
- Integrate SpecDialog and HookDialog for editing specs and hooks.
- Add search functionality and mock data for specs and hooks.

feat: add spec management API routes

- Implement API endpoints for listing specs, getting spec details, updating frontmatter, rebuilding indices, and initializing the spec system.
- Handle errors and responses appropriately for each endpoint.
This commit is contained in:
catlog22
2026-02-26 22:03:13 +08:00
parent 430d817e43
commit 6155fcc7b8
115 changed files with 4883 additions and 21127 deletions

View File

@@ -25,11 +25,12 @@ type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [
{ id: 'open', titleKey: 'issues.status.open' },
{ id: 'in_progress', titleKey: 'issues.status.inProgress' },
{ id: 'resolved', titleKey: 'issues.status.resolved' },
{ id: 'registered', titleKey: 'issues.status.registered' },
{ id: 'planning', titleKey: 'issues.status.planning' },
{ id: 'planned', titleKey: 'issues.status.planned' },
{ id: 'executing', titleKey: 'issues.status.executing' },
{ id: 'completed', titleKey: 'issues.status.completed' },
{ id: 'closed', titleKey: 'issues.status.closed' },
{ id: 'failed', titleKey: 'issues.status.failed' },
];
type BoardOrder = Partial<Record<IssueBoardStatus, string[]>>;
@@ -294,8 +295,8 @@ export function IssueBoardPanel() {
try {
await updateIssue(issueId, { status: destStatus });
// Auto action: drag to in_progress opens the drawer on terminal tab.
if (destStatus === 'in_progress' && sourceStatus !== 'in_progress') {
// Auto action: drag to executing opens the drawer on terminal tab.
if (destStatus === 'executing' && sourceStatus !== 'executing') {
setDrawerInitialTab('terminal');
setSelectedIssue({ ...issue, status: destStatus });

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
// Keep in sync with IssueHubHeader/IssueHubPage
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
export type IssueTab = 'issues' | 'queue' | 'discovery';
interface IssueHubTabsProps {
currentTab: IssueTab;
@@ -20,7 +20,6 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
const tabs: Array<{ value: IssueTab; label: string }> = [
{ value: 'issues', label: formatMessage({ id: 'issues.hub.tabs.issues' }) },
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
];

View File

@@ -1,9 +1,9 @@
// ========================================
// Issues Panel
// ========================================
// Issue list panel for IssueHub
// Unified issue list panel with list/board view toggle
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
@@ -11,7 +11,10 @@ import {
Clock,
AlertTriangle,
AlertCircle,
LayoutGrid,
List,
} from 'lucide-react';
import type { DropResult } from '@hello-pangea/dnd';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
@@ -19,11 +22,24 @@ import { Button } from '@/components/ui/Button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { IssueCard } from '@/components/shared/IssueCard';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { useIssues, useIssueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
type StatusFilter = 'all' | Issue['status'];
type PriorityFilter = 'all' | Issue['priority'];
type ViewMode = 'list' | 'board';
// Board columns matching backend status with Chinese defaults
const BOARD_COLUMNS: Array<{ id: Issue['status']; titleKey: string; defaultLabel: string }> = [
{ id: 'registered', titleKey: 'issues.status.registered', defaultLabel: '新注册' },
{ id: 'planning', titleKey: 'issues.status.planning', defaultLabel: '规划中' },
{ id: 'planned', titleKey: 'issues.status.planned', defaultLabel: '已规划' },
{ id: 'executing', titleKey: 'issues.status.executing', defaultLabel: '执行中' },
{ id: 'completed', titleKey: 'issues.status.completed', defaultLabel: '已完成' },
];
interface IssuesPanelProps {
onCreateIssue?: () => void;
@@ -77,14 +93,114 @@ function IssueList({ issues, isLoading, onIssueClick, onIssueEdit, onIssueDelete
);
}
// Local storage key for board order
function storageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issues.board.order:${base}`;
}
type BoardOrder = Partial<Record<Issue['status'], string[]>>;
function safeParseOrder(value: string | null): BoardOrder {
if (!value) return {};
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object') return {};
return parsed as BoardOrder;
} catch {
return {};
}
}
function buildColumns(
issues: Issue[],
order: BoardOrder,
formatTitle: (statusId: Issue['status']) => string
): KanbanColumn<Issue & KanbanItem>[] {
const byId = new Map(issues.map((i) => [i.id, i]));
const columns: KanbanColumn<Issue & KanbanItem>[] = [];
for (const col of BOARD_COLUMNS) {
const desired = (order[col.id] ?? []).map((id) => byId.get(id)).filter(Boolean) as Issue[];
const desiredIds = new Set(desired.map((i) => i.id));
const remaining = issues
.filter((i) => i.status === col.id && !desiredIds.has(i.id))
.sort((a, b) => {
const at = a.updatedAt || a.createdAt || '';
const bt = b.updatedAt || b.createdAt || '';
return bt.localeCompare(at);
});
const items = [...desired, ...remaining].map((issue) => ({
...issue,
id: issue.id,
title: issue.title,
status: issue.status,
}));
columns.push({
id: col.id,
title: formatTitle(col.id),
items,
icon: <LayoutGrid className="w-4 h-4" />,
});
}
return columns;
}
function syncOrderWithIssues(prev: BoardOrder, issues: Issue[]): BoardOrder {
const statusById = new Map(issues.map((i) => [i.id, i.status]));
const next: BoardOrder = {};
for (const { id: status } of BOARD_COLUMNS) {
const existing = prev[status] ?? [];
const filtered = existing.filter((id) => statusById.get(id) === status);
const present = new Set(filtered);
const missing = issues
.filter((i) => i.status === status && !present.has(i.id))
.map((i) => i.id);
next[status] = [...filtered, ...missing];
}
return next;
}
function reorderIds(list: string[], from: number, to: number): string[] {
const next = [...list];
const [moved] = next.splice(from, 1);
if (moved === undefined) return list;
next.splice(to, 0, moved);
return next;
}
export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
// View mode state
const [viewMode, setViewMode] = useState<ViewMode>('list');
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const { issues, issuesByStatus, openCount, criticalCount, isLoading } = useIssues({
// Board order state
const [order, setOrder] = useState<BoardOrder>({});
// Load board order when project changes
useEffect(() => {
const key = storageKey(projectPath);
const loaded = safeParseOrder(localStorage.getItem(key));
setOrder(loaded);
}, [projectPath]);
const { issues, criticalCount, isLoading } = useIssues({
filter: {
search: searchQuery || undefined,
status: statusFilter !== 'all' ? [statusFilter] : undefined,
@@ -94,14 +210,50 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
const { updateIssue, deleteIssue } = useIssueMutations();
// Keep order consistent with current issues
useEffect(() => {
setOrder((prev) => syncOrderWithIssues(prev, issues));
}, [issues]);
// Persist order
useEffect(() => {
const key = storageKey(projectPath);
try {
localStorage.setItem(key, JSON.stringify(order));
} catch {
// ignore quota errors
}
}, [order, projectPath]);
// Status counts using backend statuses
const statusCounts = useMemo(() => ({
all: issues.length,
open: issuesByStatus.open?.length || 0,
in_progress: issuesByStatus.in_progress?.length || 0,
resolved: issuesByStatus.resolved?.length || 0,
closed: issuesByStatus.closed?.length || 0,
completed: issuesByStatus.completed?.length || 0,
}), [issues, issuesByStatus]);
registered: issues.filter(i => i.status === 'registered').length,
planning: issues.filter(i => i.status === 'planning').length,
planned: issues.filter(i => i.status === 'planned').length,
executing: issues.filter(i => i.status === 'executing').length,
completed: issues.filter(i => i.status === 'completed').length,
failed: issues.filter(i => i.status === 'failed').length,
}), [issues]);
// Build kanban columns
const columns = useMemo(
() =>
buildColumns(issues, order, (statusId) => {
const col = BOARD_COLUMNS.find((c) => c.id === statusId);
if (!col) return statusId;
return formatMessage({ id: col.titleKey, defaultMessage: col.defaultLabel });
}),
[issues, order, formatMessage]
);
const idsByStatus = useMemo(() => {
const map: Record<string, string[]> = {};
for (const col of columns) {
map[col.id] = col.items.map((i) => i.id);
}
return map;
}, [columns]);
const handleEditIssue = (_issue: Issue) => {};
@@ -123,22 +275,66 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
setSelectedIssue(null);
};
// Board drag handler
const handleDragEnd = useCallback(
async (result: DropResult, sourceColumn: string, destColumn: string) => {
const issueId = result.draggableId;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
const sourceStatus = sourceColumn as Issue['status'];
const destStatus = destColumn as Issue['status'];
const sourceIds = idsByStatus[sourceStatus] ?? [];
const destIds = idsByStatus[destStatus] ?? [];
// Update local order first (optimistic)
setOrder((prev) => {
const next = { ...prev };
if (sourceStatus === destStatus) {
next[sourceStatus] = reorderIds(sourceIds, result.source.index, result.destination!.index);
return next;
}
const nextSource = [...sourceIds];
nextSource.splice(result.source.index, 1);
const nextDest = [...destIds];
nextDest.splice(result.destination!.index, 0, issueId);
next[sourceStatus] = nextSource;
next[destStatus] = nextDest;
return next;
});
// Status update
if (sourceStatus !== destStatus) {
try {
await updateIssue(issueId, { status: destStatus });
} catch (e) {
console.error('Failed to update issue status:', e);
}
}
},
[issues, idsByStatus, updateIssue]
);
return (
<div className="space-y-4">
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{openCount}</span>
<span className="text-2xl font-bold">{statusCounts.registered}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.status.openIssues' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
<span className="text-2xl font-bold">{statusCounts.executing}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.inProgress' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
@@ -150,12 +346,13 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
<span className="text-2xl font-bold">{statusCounts.completed}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.resolved' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.completed', defaultMessage: '已完成' })}</p>
</Card>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -166,6 +363,29 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
className="pl-9"
/>
</div>
{/* View Mode Toggle */}
<div className="flex border border-border rounded-md overflow-hidden">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="rounded-none"
onClick={() => setViewMode('list')}
>
<List className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.view.list', defaultMessage: '列表' })}
</Button>
<Button
variant={viewMode === 'board' ? 'default' : 'ghost'}
size="sm"
className="rounded-none"
onClick={() => setViewMode('board')}
>
<LayoutGrid className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.view.board', defaultMessage: '看板' })}
</Button>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="w-[140px]">
@@ -173,10 +393,12 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.filters.all' })}</SelectItem>
<SelectItem value="open">{formatMessage({ id: 'issues.status.open' })}</SelectItem>
<SelectItem value="in_progress">{formatMessage({ id: 'issues.status.inProgress' })}</SelectItem>
<SelectItem value="resolved">{formatMessage({ id: 'issues.status.resolved' })}</SelectItem>
<SelectItem value="closed">{formatMessage({ id: 'issues.status.closed' })}</SelectItem>
<SelectItem value="registered">{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}</SelectItem>
<SelectItem value="planning">{formatMessage({ id: 'issues.status.planning', defaultMessage: '规划中' })}</SelectItem>
<SelectItem value="planned">{formatMessage({ id: 'issues.status.planned', defaultMessage: '已规划' })}</SelectItem>
<SelectItem value="executing">{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}</SelectItem>
<SelectItem value="completed">{formatMessage({ id: 'issues.status.completed', defaultMessage: '已完成' })}</SelectItem>
<SelectItem value="failed">{formatMessage({ id: 'issues.status.failed', defaultMessage: '失败' })}</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
@@ -194,32 +416,59 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={statusFilter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('all')}>
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
</Button>
<Button variant={statusFilter === 'open' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('open')}>
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
{formatMessage({ id: 'issues.status.open' })}
</Button>
<Button variant={statusFilter === 'in_progress' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('in_progress')}>
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
{formatMessage({ id: 'issues.status.inProgress' })}
</Button>
<Button variant={priorityFilter === 'critical' ? 'destructive' : 'outline'} size="sm" onClick={() => { setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical'); setStatusFilter('all'); }}>
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
{formatMessage({ id: 'issues.priority.critical' })}
</Button>
</div>
{/* Status Filter Pills - only in list mode */}
{viewMode === 'list' && (
<div className="flex flex-wrap gap-2">
<Button variant={statusFilter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('all')}>
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
</Button>
<Button variant={statusFilter === 'registered' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('registered')}>
<Badge variant="info" className="mr-2">{statusCounts.registered}</Badge>
{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}
</Button>
<Button variant={statusFilter === 'executing' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('executing')}>
<Badge variant="warning" className="mr-2">{statusCounts.executing}</Badge>
{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}
</Button>
<Button variant={priorityFilter === 'critical' ? 'destructive' : 'outline'} size="sm" onClick={() => { setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical'); setStatusFilter('all'); }}>
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
{formatMessage({ id: 'issues.priority.critical' })}
</Button>
</div>
)}
<IssueList
issues={issues}
isLoading={isLoading}
onIssueClick={handleIssueClick}
onIssueEdit={handleEditIssue}
onIssueDelete={handleDeleteIssue}
onStatusChange={handleStatusChange}
/>
{/* Content Area */}
{viewMode === 'list' ? (
<IssueList
issues={issues}
isLoading={isLoading}
onIssueClick={handleIssueClick}
onIssueEdit={handleEditIssue}
onIssueDelete={handleDeleteIssue}
onStatusChange={handleStatusChange}
/>
) : (
<KanbanBoard<Issue & KanbanItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => handleIssueClick(item as unknown as Issue)}
isLoading={isLoading}
emptyColumnMessage={formatMessage({ id: 'issues.emptyState.message' })}
className={cn('gap-4', 'grid')}
renderItem={(item, provided) => (
<IssueCard
issue={item as unknown as Issue}
compact
showActions={false}
onClick={(i) => handleIssueClick(i)}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
className="w-full"
/>
)}
/>
)}
{/* Issue Detail Drawer */}
<IssueDrawer

View File

@@ -26,6 +26,7 @@ import {
Cog,
Users,
FileSearch,
ScrollText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -101,6 +102,7 @@ const navGroupDefinitions: NavGroupDef[] = [
items: [
{ path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
{ path: '/settings/specs', labelKey: 'navigation.main.specs', icon: ScrollText },
],
},
{

View File

@@ -16,6 +16,7 @@ import {
CheckCircle,
Clock,
XCircle,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card } from '@/components/ui/Card';
@@ -61,20 +62,26 @@ const priorityLabelKeys: Record<Issue['priority'], string> = {
// Status icon and color configuration (without labels for i18n)
const statusVariantConfig: Record<Issue['status'], { icon: React.ElementType; color: string }> = {
open: { icon: AlertCircle, color: 'info' },
in_progress: { icon: Clock, color: 'warning' },
resolved: { icon: CheckCircle, color: 'success' },
closed: { icon: XCircle, color: 'muted' },
registered: { icon: AlertCircle, color: 'info' },
planning: { icon: Clock, color: 'warning' },
planned: { icon: Clock, color: 'warning' },
queued: { icon: Clock, color: 'warning' },
executing: { icon: Loader2, color: 'warning' },
completed: { icon: CheckCircle, color: 'success' },
failed: { icon: XCircle, color: 'destructive' },
paused: { icon: Clock, color: 'muted' },
};
// Status label keys for i18n
const statusLabelKeys: Record<Issue['status'], string> = {
open: 'issues.status.open',
in_progress: 'issues.status.inProgress',
resolved: 'issues.status.resolved',
closed: 'issues.status.closed',
registered: 'issues.status.registered',
planning: 'issues.status.planning',
planned: 'issues.status.planned',
queued: 'issues.status.queued',
executing: 'issues.status.executing',
completed: 'issues.status.completed',
failed: 'issues.status.failed',
paused: 'issues.status.paused',
};
// ========== Priority Badge ==========
@@ -228,11 +235,11 @@ export function IssueCard({
<Edit className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'in_progress')}>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'executing')}>
<Clock className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.startProgress' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'resolved')}>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'completed')}>
<CheckCircle className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.markResolved' })}
</DropdownMenuItem>

View File

@@ -0,0 +1,341 @@
// ========================================
// GlobalSettingsTab Component
// ========================================
// Global settings for personal spec defaults and spec statistics
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Settings, RefreshCw } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Switch } from '@/components/ui/Switch';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { cn } from '@/lib/utils';
// ========== Types ==========
interface PersonalSpecDefaults {
defaultReadMode: 'required' | 'optional';
autoEnable: boolean;
}
interface SystemSettings {
injectionControl: {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
};
personalSpecDefaults: PersonalSpecDefaults;
}
interface SpecDimensionStats {
count: number;
requiredCount: number;
}
interface SpecStats {
dimensions: {
specs: SpecDimensionStats;
roadmap: SpecDimensionStats;
changelog: SpecDimensionStats;
personal: SpecDimensionStats;
};
injectionLength?: {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
};
}
// ========== API Functions ==========
const API_BASE = '/api';
async function fetchSystemSettings(): Promise<SystemSettings> {
const response = await fetch(`${API_BASE}/system/settings`, {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
const data = await response.json();
return data;
}
async function updateSystemSettings(
settings: Partial<SystemSettings>
): Promise<{ success: boolean; settings: SystemSettings }> {
const response = await fetch(`${API_BASE}/system/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(`Failed to update settings: ${response.statusText}`);
}
return response.json();
}
async function fetchSpecStats(): Promise<SpecStats> {
const response = await fetch(`${API_BASE}/specs/stats`, {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to fetch spec stats: ${response.statusText}`);
}
return response.json();
}
// ========== Query Keys ==========
const settingsKeys = {
all: ['system-settings'] as const,
settings: () => [...settingsKeys.all, 'settings'] as const,
stats: () => [...settingsKeys.all, 'stats'] as const,
};
// ========== Component ==========
export function GlobalSettingsTab() {
const queryClient = useQueryClient();
// Local state for immediate UI feedback
const [localDefaults, setLocalDefaults] = useState<PersonalSpecDefaults>({
defaultReadMode: 'optional',
autoEnable: true,
});
// Fetch system settings
const {
data: settings,
isLoading: isLoadingSettings,
error: settingsError,
} = useQuery({
queryKey: settingsKeys.settings(),
queryFn: fetchSystemSettings,
staleTime: 60000, // 1 minute
});
// Fetch spec stats
const {
data: stats,
isLoading: isLoadingStats,
error: statsError,
refetch: refetchStats,
} = useQuery({
queryKey: settingsKeys.stats(),
queryFn: fetchSpecStats,
staleTime: 30000, // 30 seconds
});
// Update settings mutation
const updateMutation = useMutation({
mutationFn: updateSystemSettings,
onSuccess: (data) => {
queryClient.setQueryData(settingsKeys.settings(), data.settings);
toast.success('Settings saved successfully');
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`);
},
});
// Sync local state with server state
useEffect(() => {
if (settings?.personalSpecDefaults) {
setLocalDefaults(settings.personalSpecDefaults);
}
}, [settings]);
// Handlers
const handleReadModeChange = (value: 'required' | 'optional') => {
const newDefaults = { ...localDefaults, defaultReadMode: value };
setLocalDefaults(newDefaults);
updateMutation.mutate({ personalSpecDefaults: newDefaults });
};
const handleAutoEnableChange = (checked: boolean) => {
const newDefaults = { ...localDefaults, autoEnable: checked };
setLocalDefaults(newDefaults);
updateMutation.mutate({ personalSpecDefaults: newDefaults });
};
// Calculate totals
const dimensions = stats?.dimensions || {};
const dimensionEntries = Object.entries(dimensions) as [
keyof typeof dimensions,
SpecDimensionStats
][];
const totalCount = dimensionEntries.reduce(
(sum, [, data]) => sum + data.count,
0
);
const totalRequired = dimensionEntries.reduce(
(sum, [, data]) => sum + data.requiredCount,
0
);
const isLoading = isLoadingSettings || isLoadingStats;
const hasError = settingsError || statsError;
// Dimension display config
const dimensionLabels: Record<string, string> = {
specs: 'Specs',
roadmap: 'Roadmap',
changelog: 'Changelog',
personal: 'Personal',
};
return (
<div className="space-y-6">
{/* Personal Spec Defaults Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<CardTitle>Personal Spec Defaults</CardTitle>
</div>
<CardDescription>
These settings will be applied when creating new personal specs
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Default Read Mode */}
<div className="space-y-2">
<Label htmlFor="default-read-mode">Default Read Mode</Label>
<Select
value={localDefaults.defaultReadMode}
onValueChange={(value) =>
handleReadModeChange(value as 'required' | 'optional')
}
>
<SelectTrigger id="default-read-mode" className="w-full">
<SelectValue placeholder="Select read mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="required">
Required (Always inject)
</SelectItem>
<SelectItem value="optional">
Optional (Inject on keyword match)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
The default read mode for newly created personal specs
</p>
</div>
{/* Auto Enable */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="auto-enable">Auto Enable New Specs</Label>
<p className="text-sm text-muted-foreground">
Automatically enable newly created personal specs
</p>
</div>
<Switch
id="auto-enable"
checked={localDefaults.autoEnable}
onCheckedChange={handleAutoEnableChange}
disabled={updateMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Spec Statistics Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Spec Statistics</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => refetchStats()}
disabled={isLoadingStats}
>
<RefreshCw
className={cn(
'h-4 w-4',
isLoadingStats && 'animate-spin'
)}
/>
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="text-center p-4 rounded-lg bg-muted animate-pulse"
>
<div className="h-8 w-12 mx-auto bg-muted-foreground/20 rounded mb-2" />
<div className="h-4 w-16 mx-auto bg-muted-foreground/20 rounded" />
</div>
))}
</div>
) : hasError ? (
<div className="text-center py-8 text-muted-foreground">
Failed to load statistics
</div>
) : (
<>
<div className="grid grid-cols-4 gap-4">
{dimensionEntries.map(([dim, data]) => (
<div
key={dim}
className="text-center p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
>
<div className="text-2xl font-bold text-foreground">
{data.count}
</div>
<div className="text-sm text-muted-foreground capitalize">
{dimensionLabels[dim] || dim}
</div>
<div className="text-xs text-muted-foreground mt-1">
{data.requiredCount} required
</div>
</div>
))}
</div>
{/* Summary */}
<div className="mt-4 pt-4 border-t border-border">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Total: {totalCount} spec files</span>
<span>
{totalRequired} required | {totalCount - totalRequired}{' '}
optional
</span>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
export default GlobalSettingsTab;

View File

@@ -0,0 +1,318 @@
// ========================================
// HookCard Component
// ========================================
// Hook card with event badge, scope badge and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import {
Zap,
MoreVertical,
Edit,
Trash2,
Globe,
Folder,
Play,
Clock,
Terminal,
} from 'lucide-react';
/**
* Hook event types
*/
export type HookEvent = 'SessionStart' | 'UserPromptSubmit' | 'SessionEnd';
/**
* Hook scope types
*/
export type HookScope = 'global' | 'project';
/**
* Fail mode for hooks
*/
export type HookFailMode = 'continue' | 'block' | 'warn';
/**
* Hook configuration interface
*/
export interface HookConfig {
/** Unique hook identifier */
id: string;
/** Hook name */
name: string;
/** Trigger event */
event: HookEvent;
/** Command to execute */
command: string;
/** Description */
description?: string;
/** Scope (global or project) */
scope: HookScope;
/** Whether hook is enabled */
enabled: boolean;
/** Whether hook is installed */
installed?: boolean;
/** Whether this is a recommended hook */
isRecommended?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Fail mode */
failMode?: HookFailMode;
}
export interface HookCardProps {
/** Hook data */
hook: HookConfig;
/** Called when edit action is triggered */
onEdit?: (hook: HookConfig) => void;
/** Called when uninstall action is triggered */
onUninstall?: (hookId: string) => void;
/** Called when toggle enabled is triggered */
onToggle?: (hookId: string, enabled: boolean) => void;
/** Optional className */
className?: string;
/** Show actions dropdown */
showActions?: boolean;
/** Disabled state for actions */
actionsDisabled?: boolean;
/** Whether this is a recommended hook card */
isRecommendedCard?: boolean;
/** Called when install action is triggered (recommended hooks only) */
onInstall?: (hookId: string) => void;
}
// Event type configuration
const eventConfig: Record<
HookEvent,
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ReactNode }
> = {
SessionStart: { variant: 'success' as const, icon: <Play className="h-3 w-3" /> },
UserPromptSubmit: { variant: 'info' as const, icon: <Terminal className="h-3 w-3" /> },
SessionEnd: { variant: 'secondary' as const, icon: <Clock className="h-3 w-3" /> },
};
// Event label keys for i18n
const eventLabelKeys: Record<HookEvent, string> = {
SessionStart: 'hooks.events.sessionStart',
UserPromptSubmit: 'hooks.events.userPromptSubmit',
SessionEnd: 'hooks.events.sessionEnd',
};
// Scope label keys for i18n
const scopeLabelKeys: Record<HookScope, string> = {
global: 'hooks.scope.global',
project: 'hooks.scope.project',
};
/**
* HookCard component for displaying hook information
*/
export function HookCard({
hook,
onEdit,
onUninstall,
onToggle,
className,
showActions = true,
actionsDisabled = false,
isRecommendedCard = false,
onInstall,
}: HookCardProps) {
const { formatMessage } = useIntl();
const { variant: eventVariant, icon: eventIcon } = eventConfig[hook.event] || {
variant: 'default' as const,
icon: <Zap className="h-3 w-3" />,
};
const eventLabel = formatMessage({ id: eventLabelKeys[hook.event] || 'hooks.events.unknown' });
const scopeIcon = hook.scope === 'global' ? <Globe className="h-3 w-3" /> : <Folder className="h-3 w-3" />;
const scopeLabel = formatMessage({ id: scopeLabelKeys[hook.scope] });
const handleToggle = (enabled: boolean) => {
onToggle?.(hook.id, enabled);
};
const handleAction = (e: React.MouseEvent, action: 'edit' | 'uninstall' | 'install') => {
e.stopPropagation();
switch (action) {
case 'edit':
onEdit?.(hook);
break;
case 'uninstall':
onUninstall?.(hook.id);
break;
case 'install':
onInstall?.(hook.id);
break;
}
};
// For recommended hooks that are not installed
if (isRecommendedCard && !hook.installed) {
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
className
)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-card-foreground truncate">
{hook.name}
</h3>
<Badge variant="outline" className="gap-1">
{scopeIcon}
{scopeLabel}
</Badge>
</div>
{hook.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{hook.description}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => handleAction(e, 'install')}
disabled={actionsDisabled}
className="ml-4"
>
{formatMessage({ id: 'hooks.actions.install' })}
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!hook.enabled && 'opacity-60',
className
)}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-card-foreground truncate">
{hook.name}
</h3>
<Badge variant="outline" className="gap-1" title={scopeLabel}>
{scopeIcon}
</Badge>
</div>
{hook.description && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-1">
{hook.description}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={eventVariant} className="gap-1">
{eventIcon}
{eventLabel}
</Badge>
<Switch
checked={hook.enabled}
onCheckedChange={handleToggle}
disabled={actionsDisabled}
className="data-[state=checked]:bg-primary"
/>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'hooks.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'uninstall')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'hooks.actions.uninstall' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Command info */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
<Terminal className="h-3.5 w-3.5" />
{hook.command}
</span>
{hook.timeout && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{hook.timeout}ms
</span>
)}
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for HookCard
*/
export function HookCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-5 w-32 rounded bg-muted" />
<div className="mt-1 h-3 w-48 rounded bg-muted" />
</div>
<div className="flex items-center gap-2">
<div className="h-5 w-20 rounded bg-muted" />
<div className="h-5 w-8 rounded-full bg-muted" />
<div className="h-8 w-8 rounded bg-muted" />
</div>
</div>
<div className="mt-3 flex gap-4">
<div className="h-3 w-32 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,354 @@
// ========================================
// HookDialog Component
// ========================================
// Dialog for editing hook configuration
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Textarea } from '@/components/ui/Textarea';
import { Globe, Folder, HelpCircle } from 'lucide-react';
import {
HookConfig,
HookEvent,
HookScope,
HookFailMode,
} from './HookCard';
export interface HookDialogProps {
/** Whether dialog is open */
open: boolean;
/** Called when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Hook data to edit (undefined for new hook) */
hook?: HookConfig;
/** Called when save is triggered */
onSave: (hook: Omit<HookConfig, 'id'> & { id?: string }) => void;
/** Optional className */
className?: string;
/** Loading state */
isLoading?: boolean;
}
/**
* Default hook configuration
*/
const defaultHookConfig: Omit<HookConfig, 'id'> = {
name: '',
event: 'SessionStart',
command: '',
description: '',
scope: 'global',
enabled: true,
installed: true,
timeout: 30000,
failMode: 'continue',
};
/**
* HookDialog component for editing hook configuration
*/
export function HookDialog({
open,
onOpenChange,
hook,
onSave,
className,
isLoading = false,
}: HookDialogProps) {
const { formatMessage } = useIntl();
const isEditing = !!hook;
// Form state
const [formData, setFormData] = React.useState<Omit<HookConfig, 'id'>>(defaultHookConfig);
const [errors, setErrors] = React.useState<Record<string, string>>({});
// Reset form when hook changes or dialog opens
React.useEffect(() => {
if (open) {
setFormData(hook ? { ...hook } : defaultHookConfig);
setErrors({});
}
}, [open, hook]);
// Update form field
const updateField = <K extends keyof typeof formData>(
field: K,
value: (typeof formData)[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when field is updated
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// Validate form
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = formatMessage({ id: 'hooks.validation.nameRequired' });
}
if (!formData.command.trim()) {
newErrors.command = formatMessage({ id: 'hooks.validation.commandRequired' });
}
if (formData.timeout && formData.timeout < 1000) {
newErrors.timeout = formatMessage({ id: 'hooks.validation.timeoutMin' });
}
if (formData.timeout && formData.timeout > 300000) {
newErrors.timeout = formatMessage({ id: 'hooks.validation.timeoutMax' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle save
const handleSave = () => {
if (!validateForm()) {
return;
}
onSave({
...(hook ? { id: hook.id } : {}),
...formData,
});
};
// Handle cancel
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('sm:max-w-[500px]', className)}>
<DialogHeader>
<DialogTitle>
{isEditing
? formatMessage({ id: 'hooks.dialog.editTitle' })
: formatMessage({ id: 'hooks.dialog.createTitle' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'hooks.dialog.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name field */}
<div className="space-y-2">
<Label htmlFor="name" className="required">
{formatMessage({ id: 'hooks.fields.name' })}
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.name' })}
className={errors.name ? 'border-destructive' : ''}
disabled={isLoading}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
)}
</div>
{/* Event type field */}
<div className="space-y-2">
<Label htmlFor="event" className="required">
{formatMessage({ id: 'hooks.fields.event' })}
</Label>
<Select
value={formData.event}
onValueChange={(value) => updateField('event', value as HookEvent)}
disabled={isLoading}
>
<SelectTrigger id="event">
<SelectValue placeholder={formatMessage({ id: 'hooks.placeholders.event' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="SessionStart">
{formatMessage({ id: 'hooks.events.sessionStart' })}
</SelectItem>
<SelectItem value="UserPromptSubmit">
{formatMessage({ id: 'hooks.events.userPromptSubmit' })}
</SelectItem>
<SelectItem value="SessionEnd">
{formatMessage({ id: 'hooks.events.sessionEnd' })}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookEvents' })}
</p>
</div>
{/* Scope field */}
<div className="space-y-2">
<Label className="required">
{formatMessage({ id: 'hooks.fields.scope' })}
</Label>
<RadioGroup
value={formData.scope}
onValueChange={(value) => updateField('scope', value as HookScope)}
className="flex gap-4"
disabled={isLoading}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="global" id="scope-global" />
<Label htmlFor="scope-global" className="flex items-center gap-1.5 cursor-pointer">
<Globe className="h-4 w-4" />
{formatMessage({ id: 'hooks.scope.global' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="project" id="scope-project" />
<Label htmlFor="scope-project" className="flex items-center gap-1.5 cursor-pointer">
<Folder className="h-4 w-4" />
{formatMessage({ id: 'hooks.scope.project' })}
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookScope' })}
</p>
</div>
{/* Command field */}
<div className="space-y-2">
<Label htmlFor="command" className="required">
{formatMessage({ id: 'hooks.fields.command' })}
</Label>
<Input
id="command"
value={formData.command}
onChange={(e) => updateField('command', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.command' })}
className={cn('font-mono', errors.command ? 'border-destructive' : '')}
disabled={isLoading}
/>
{errors.command && (
<p className="text-xs text-destructive">{errors.command}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookCommand' })}
</p>
</div>
{/* Description field */}
<div className="space-y-2">
<Label htmlFor="description">
{formatMessage({ id: 'hooks.fields.description' })}
</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.description' })}
rows={2}
disabled={isLoading}
/>
</div>
{/* Timeout field */}
<div className="space-y-2">
<Label htmlFor="timeout" className="flex items-center gap-1">
{formatMessage({ id: 'hooks.fields.timeout' })}
<span className="text-xs text-muted-foreground">
({formatMessage({ id: 'hooks.fields.timeoutUnit' })})
</span>
</Label>
<Input
id="timeout"
type="number"
value={formData.timeout || ''}
onChange={(e) => updateField('timeout', parseInt(e.target.value, 10) || undefined)}
placeholder="30000"
className={errors.timeout ? 'border-destructive' : ''}
disabled={isLoading}
min={1000}
max={300000}
/>
{errors.timeout && (
<p className="text-xs text-destructive">{errors.timeout}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookTimeout' })}
</p>
</div>
{/* Fail mode field */}
<div className="space-y-2">
<Label htmlFor="failMode" className="flex items-center gap-1">
{formatMessage({ id: 'hooks.fields.failMode' })}
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</Label>
<Select
value={formData.failMode || 'continue'}
onValueChange={(value) => updateField('failMode', value as HookFailMode)}
disabled={isLoading}
>
<SelectTrigger id="failMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="continue">
{formatMessage({ id: 'hooks.failModes.continue' })}
</SelectItem>
<SelectItem value="warn">
{formatMessage({ id: 'hooks.failModes.warn' })}
</SelectItem>
<SelectItem value="block">
{formatMessage({ id: 'hooks.failModes.block' })}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookFailMode' })}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
{formatMessage({ id: 'common.cancel' })}
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading
? formatMessage({ id: 'common.saving' })
: formatMessage({ id: 'common.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { type HookConfig, type HookEvent, type HookScope, type HookFailMode } from './HookCard';

View File

@@ -0,0 +1,474 @@
// ========================================
// InjectionControlTab Component
// ========================================
// Tab for managing spec injection control settings
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Progress } from '@/components/ui/Progress';
import {
AlertCircle,
Info,
Loader2,
RefreshCw,
AlertTriangle,
} from 'lucide-react';
// ========== Types ==========
export interface InjectionStats {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
}
export interface SpecStatsResponse {
dimensions: {
specs: { count: number; requiredCount: number };
roadmap: { count: number; requiredCount: number };
changelog: { count: number; requiredCount: number };
personal: { count: number; requiredCount: number };
};
injectionLength: InjectionStats;
}
export interface InjectionSettings {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
}
export interface SystemSettingsResponse {
injectionControl: InjectionSettings;
personalSpecDefaults: {
defaultReadMode: 'required' | 'optional';
autoEnable: boolean;
};
}
export interface InjectionControlTabProps {
className?: string;
}
// ========== API Functions ==========
async function fetchSpecStats(): Promise<SpecStatsResponse> {
const response = await fetch('/api/specs/stats');
if (!response.ok) {
throw new Error('Failed to fetch spec stats');
}
return response.json();
}
async function fetchSystemSettings(): Promise<SystemSettingsResponse> {
const response = await fetch('/api/system/settings');
if (!response.ok) {
throw new Error('Failed to fetch system settings');
}
return response.json();
}
async function updateInjectionSettings(
settings: Partial<InjectionSettings>
): Promise<SystemSettingsResponse> {
const currentSettings = await fetchSystemSettings();
const response = await fetch('/api/system/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
injectionControl: {
...currentSettings.injectionControl,
...settings,
},
}),
});
if (!response.ok) {
throw new Error('Failed to update settings');
}
return response.json();
}
// ========== Helper Functions ==========
function formatNumber(num: number): string {
return num.toLocaleString();
}
function calculatePercentage(current: number, max: number): number {
if (max <= 0) return 0;
return Math.min(100, (current / max) * 100);
}
// ========== Component ==========
export function InjectionControlTab({ className }: InjectionControlTabProps) {
const { formatMessage } = useIntl();
// State for stats
const [stats, setStats] = useState<SpecStatsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [statsError, setStatsError] = useState<Error | null>(null);
// State for settings
const [settings, setSettings] = useState<InjectionSettings>({
maxLength: 8000,
warnThreshold: 6000,
truncateOnExceed: true,
});
const [settingsLoading, setSettingsLoading] = useState(true);
// Form state (for local editing before save)
const [formData, setFormData] = useState<InjectionSettings>(settings);
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Fetch stats
const loadStats = useCallback(async () => {
setStatsLoading(true);
setStatsError(null);
try {
const data = await fetchSpecStats();
setStats(data);
} catch (err) {
setStatsError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setStatsLoading(false);
}
}, []);
// Fetch settings
const loadSettings = useCallback(async () => {
setSettingsLoading(true);
try {
const data = await fetchSystemSettings();
setSettings(data.injectionControl);
setFormData(data.injectionControl);
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
setSettingsLoading(false);
}
}, []);
// Initial load
useEffect(() => {
loadStats();
loadSettings();
}, [loadStats, loadSettings]);
// Check for changes
useEffect(() => {
const changed =
formData.maxLength !== settings.maxLength ||
formData.warnThreshold !== settings.warnThreshold ||
formData.truncateOnExceed !== settings.truncateOnExceed;
setHasChanges(changed);
}, [formData, settings]);
// Handle form field changes
const handleFieldChange = <K extends keyof InjectionSettings>(
field: K,
value: InjectionSettings[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Save settings
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateInjectionSettings(formData);
setSettings(result.injectionControl);
setFormData(result.injectionControl);
setHasChanges(false);
toast.success(
formatMessage({ id: 'specs.injection.saveSuccess' }, { default: 'Settings saved successfully' })
);
} catch (err) {
toast.error(
formatMessage({ id: 'specs.injection.saveError' }, { default: 'Failed to save settings' })
);
console.error('Failed to save settings:', err);
} finally {
setIsSaving(false);
}
};
// Reset form
const handleReset = () => {
setFormData(settings);
setHasChanges(false);
};
// Calculate progress and status
const currentLength = stats?.injectionLength?.withKeywords || 0;
const maxLength = settings.maxLength;
const warnThreshold = settings.warnThreshold;
const percentage = calculatePercentage(currentLength, maxLength);
const isOverLimit = currentLength > maxLength;
const isOverWarning = currentLength > warnThreshold;
const remainingSpace = Math.max(0, maxLength - currentLength);
return (
<div className={cn('space-y-6', className)}>
{/* Current Injection Status Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{formatMessage(
{ id: 'specs.injection.statusTitle', defaultMessage: 'Current Injection Status' }
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={loadStats}
disabled={statsLoading}
>
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{statsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : statsError ? (
<div className="text-sm text-destructive">
{formatMessage(
{ id: 'specs.injection.loadError', defaultMessage: 'Failed to load stats' }
)}
</div>
) : (
<>
{/* Current Length Display */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.currentLength', defaultMessage: 'Current Length' })}
</span>
<span
className={cn(
'font-medium',
isOverLimit && 'text-destructive',
!isOverLimit && isOverWarning && 'text-yellow-600 dark:text-yellow-400'
)}
>
{formatNumber(currentLength)} / {formatNumber(maxLength)}{' '}
{formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</span>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={percentage}
className={cn(
'h-3',
isOverLimit && 'bg-destructive/20',
!isOverLimit && isOverWarning && 'bg-yellow-100 dark:bg-yellow-900/30'
)}
indicatorClassName={cn(
isOverLimit && 'bg-destructive',
!isOverLimit && isOverWarning && 'bg-yellow-500'
)}
/>
{/* Warning threshold marker */}
<div
className="relative h-0"
style={{
left: `${Math.min(100, (warnThreshold / maxLength) * 100)}%`,
}}
>
<div className="absolute -top-5 transform -translate-x-1/2 flex flex-col items-center">
<AlertTriangle className="h-3 w-3 text-yellow-500" />
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatMessage({ id: 'specs.injection.warnThreshold', defaultMessage: 'Warn' })}
</div>
</div>
</div>
</div>
{/* Warning Alert when over limit */}
{isOverLimit && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-destructive">
{formatMessage({ id: 'specs.injection.overLimit', defaultMessage: 'Over Limit' })}
</div>
<div className="text-sm text-muted-foreground mt-1">
{formatMessage(
{
id: 'specs.injection.overLimitDescription',
defaultMessage: 'Current injection content exceeds maximum length of {max} characters. Excess content will be truncated.',
},
{ max: formatNumber(maxLength) }
)}
</div>
</div>
</div>
)}
{/* Statistics Info */}
<div className="p-4 rounded-lg bg-muted/50 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{formatMessage({ id: 'specs.injection.statsInfo', defaultMessage: 'Statistics' })}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.requiredLength', defaultMessage: 'Required specs length:' })}
</div>
<div className="text-right">
{formatNumber(stats?.injectionLength?.requiredOnly || 0)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</div>
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.matchedLength', defaultMessage: 'Keyword-matched length:' })}
</div>
<div className="text-right">
{formatNumber(stats?.injectionLength?.withKeywords || 0)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</div>
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.remaining', defaultMessage: 'Remaining space:' })}
</div>
<div className={cn('text-right', remainingSpace === 0 && 'text-destructive')}>
{formatNumber(remainingSpace)} ({Math.round(100 - percentage)}%)
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Settings Card */}
<Card>
<CardHeader>
<CardTitle>
{formatMessage({ id: 'specs.injection.settingsTitle', defaultMessage: 'Injection Control Settings' })}
</CardTitle>
<CardDescription>
{formatMessage({
id: 'specs.injection.settingsDescription',
defaultMessage: 'Configure how spec content is injected into AI context.',
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{settingsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Max Injection Length */}
<div className="space-y-2">
<Label htmlFor="maxLength">
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length (characters)' })}
</Label>
<Input
id="maxLength"
type="number"
min={1000}
max={50000}
step={500}
value={formData.maxLength}
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
/>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.maxLengthHelp',
defaultMessage: 'Recommended: 4000-10000. Too large may consume too much context; too small may truncate important specs.',
})}
</p>
</div>
{/* Warning Threshold */}
<div className="space-y-2">
<Label htmlFor="warnThreshold">
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold (characters)' })}
</Label>
<Input
id="warnThreshold"
type="number"
min={500}
max={formData.maxLength - 1}
step={500}
value={formData.warnThreshold}
onChange={(e) => handleFieldChange('warnThreshold', Number(e.target.value))}
/>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.warnThresholdHelp',
defaultMessage: 'A warning will be displayed when injection length exceeds this value.',
})}
</p>
</div>
{/* Truncate on Exceed */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="truncate">
{formatMessage({ id: 'specs.injection.truncateOnExceed', defaultMessage: 'Truncate on Exceed' })}
</Label>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.truncateHelp',
defaultMessage: 'Automatically truncate content when it exceeds the maximum length.',
})}
</p>
</div>
<Switch
id="truncate"
checked={formData.truncateOnExceed}
onCheckedChange={(checked) => handleFieldChange('truncateOnExceed', checked)}
/>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || isSaving}
>
{formatMessage({ id: 'common.actions.reset', defaultMessage: 'Reset' })}
</Button>
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{formatMessage({ id: 'common.actions.saving', defaultMessage: 'Saving...' })}
</>
) : (
formatMessage({ id: 'common.actions.save', defaultMessage: 'Save' })
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
export default InjectionControlTab;

View File

@@ -0,0 +1,289 @@
// ========================================
// SpecCard Component
// ========================================
// Spec card with readMode badge, keywords, priority indicator and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/DropdownMenu';
import {
MoreVertical,
Edit,
Trash2,
FileText,
Tag,
} from 'lucide-react';
// ========== Types ==========
/**
* Spec dimension type
*/
export type SpecDimension = 'specs' | 'roadmap' | 'changelog' | 'personal';
/**
* Spec read mode type
*/
export type SpecReadMode = 'required' | 'optional';
/**
* Spec priority type
*/
export type SpecPriority = 'critical' | 'high' | 'medium' | 'low';
/**
* Spec data structure
*/
export interface Spec {
/** Unique spec identifier */
id: string;
/** Spec title (display name) */
title: string;
/** Spec file path */
file: string;
/** Spec dimension/category */
dimension: SpecDimension;
/** Read mode: required (always inject) or optional (keyword match) */
readMode: SpecReadMode;
/** Priority level */
priority: SpecPriority;
/** Keywords for matching (optional specs) */
keywords: string[];
/** Whether spec is enabled */
enabled: boolean;
/** Optional description */
description?: string;
}
/**
* SpecCard component props
*/
export interface SpecCardProps {
/** Spec data */
spec: Spec;
/** Called when edit action is triggered */
onEdit?: (spec: Spec) => void;
/** Called when delete action is triggered */
onDelete?: (specId: string) => void;
/** Called when toggle enabled is triggered */
onToggle?: (specId: string, enabled: boolean) => void;
/** Optional className */
className?: string;
/** Show actions dropdown */
showActions?: boolean;
/** Disabled state for actions */
actionsDisabled?: boolean;
}
// ========== Configuration ==========
// Read mode badge configuration
const readModeConfig: Record<
SpecReadMode,
{ variant: 'default' | 'secondary'; labelKey: string }
> = {
required: { variant: 'default', labelKey: 'specs.readMode.required' },
optional: { variant: 'secondary', labelKey: 'specs.readMode.optional' },
};
// Priority badge configuration
const priorityConfig: Record<
SpecPriority,
{ variant: 'destructive' | 'warning' | 'info' | 'secondary'; labelKey: string }
> = {
critical: { variant: 'destructive', labelKey: 'specs.priority.critical' },
high: { variant: 'warning', labelKey: 'specs.priority.high' },
medium: { variant: 'info', labelKey: 'specs.priority.medium' },
low: { variant: 'secondary', labelKey: 'specs.priority.low' },
};
// ========== Component ==========
/**
* SpecCard component for displaying spec information
*/
export function SpecCard({
spec,
onEdit,
onDelete,
onToggle,
className,
showActions = true,
actionsDisabled = false,
}: SpecCardProps) {
const { formatMessage } = useIntl();
const readMode = readModeConfig[spec.readMode];
const priority = priorityConfig[spec.priority];
const handleToggle = (enabled: boolean) => {
onToggle?.(spec.id, enabled);
};
const handleAction = (e: React.MouseEvent, action: 'edit' | 'delete') => {
e.stopPropagation();
switch (action) {
case 'edit':
onEdit?.(spec);
break;
case 'delete':
onDelete?.(spec.id);
break;
}
};
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!spec.enabled && 'opacity-60',
className
)}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="font-medium text-card-foreground truncate">
{spec.title}
</h3>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{spec.file}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'delete')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.delete' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Badges */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant={readMode.variant} className="text-xs">
{formatMessage({ id: readMode.labelKey })}
</Badge>
<Badge variant={priority.variant} className="text-xs">
{formatMessage({ id: priority.labelKey })}
</Badge>
</div>
{/* Description */}
{spec.description && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
{spec.description}
</p>
)}
{/* Keywords */}
{spec.keywords.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{spec.keywords.slice(0, 4).map((keyword) => (
<Badge key={keyword} variant="outline" className="text-xs px-1.5 py-0">
{keyword}
</Badge>
))}
{spec.keywords.length > 4 && (
<span className="text-xs text-muted-foreground">
+{spec.keywords.length - 4}
</span>
)}
</div>
)}
{/* Footer with toggle */}
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: spec.enabled ? 'specs.status.enabled' : 'specs.status.disabled' })}
</span>
<Switch
checked={spec.enabled}
onCheckedChange={handleToggle}
disabled={actionsDisabled}
className="data-[state=checked]:bg-primary"
/>
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for SpecCard
*/
export function SpecCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-muted" />
<div className="h-5 w-32 rounded bg-muted" />
</div>
<div className="mt-1 h-3 w-24 rounded bg-muted" />
</div>
<div className="h-8 w-8 rounded bg-muted" />
</div>
<div className="mt-3 flex gap-2">
<div className="h-5 w-14 rounded-full bg-muted" />
<div className="h-5 w-12 rounded-full bg-muted" />
</div>
<div className="mt-3 h-4 w-full rounded bg-muted" />
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-16 rounded bg-muted" />
<div className="h-5 w-14 rounded bg-muted" />
<div className="h-5 w-12 rounded bg-muted" />
</div>
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-5 w-9 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
}
export default SpecCard;

View File

@@ -0,0 +1,319 @@
// ========================================
// SpecDialog Component
// ========================================
// Dialog for editing spec frontmatter (title, readMode, priority, keywords)
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { Tag, X } from 'lucide-react';
import type { Spec, SpecReadMode, SpecPriority } from './SpecCard';
// ========== Types ==========
/**
* Spec form data for editing
*/
export interface SpecFormData {
title: string;
readMode: SpecReadMode;
priority: SpecPriority;
keywords: string[];
}
/**
* SpecDialog component props
*/
export interface SpecDialogProps {
/** Whether dialog is open */
open: boolean;
/** Called when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Spec being edited */
spec: Spec | null;
/** Called when save is clicked */
onSave: (specId: string, data: SpecFormData) => Promise<void> | void;
/** Optional loading state */
isLoading?: boolean;
}
// ========== Constants ==========
const READ_MODE_OPTIONS: { value: SpecReadMode; labelKey: string }[] = [
{ value: 'required', labelKey: 'specs.readMode.required' },
{ value: 'optional', labelKey: 'specs.readMode.optional' },
];
const PRIORITY_OPTIONS: { value: SpecPriority; labelKey: string }[] = [
{ value: 'critical', labelKey: 'specs.priority.critical' },
{ value: 'high', labelKey: 'specs.priority.high' },
{ value: 'medium', labelKey: 'specs.priority.medium' },
{ value: 'low', labelKey: 'specs.priority.low' },
];
// ========== Component ==========
/**
* SpecDialog component for editing spec frontmatter
*/
export function SpecDialog({
open,
onOpenChange,
spec,
onSave,
isLoading = false,
}: SpecDialogProps) {
const { formatMessage } = useIntl();
const [formData, setFormData] = React.useState<SpecFormData>({
title: '',
readMode: 'optional',
priority: 'medium',
keywords: [],
});
const [keywordInput, setKeywordInput] = React.useState('');
const [errors, setErrors] = React.useState<Partial<Record<keyof SpecFormData, string>>>({});
// Reset form when spec changes
React.useEffect(() => {
if (spec) {
setFormData({
title: spec.title,
readMode: spec.readMode,
priority: spec.priority,
keywords: [...spec.keywords],
});
setErrors({});
setKeywordInput('');
}
}, [spec]);
// Validate form
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof SpecFormData, string>> = {};
if (!formData.title.trim()) {
newErrors.title = formatMessage({ id: 'specs.validation.titleRequired' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle save
const handleSave = async () => {
if (!spec || !validateForm()) return;
await onSave(spec.id, formData);
};
// Handle keyword input
const handleKeywordKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
};
// Add keyword
const addKeyword = () => {
const keyword = keywordInput.trim().toLowerCase();
if (keyword && !formData.keywords.includes(keyword)) {
setFormData((prev) => ({
...prev,
keywords: [...prev.keywords, keyword],
}));
}
setKeywordInput('');
};
// Remove keyword
const removeKeyword = (keyword: string) => {
setFormData((prev) => ({
...prev,
keywords: prev.keywords.filter((k) => k !== keyword),
}));
};
if (!spec) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'specs.dialog.editTitle' }, { title: spec.title })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'specs.dialog.editDescription' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Title field */}
<div className="space-y-2">
<Label htmlFor="title">
{formatMessage({ id: 'specs.form.title' })}
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
placeholder={formatMessage({ id: 'specs.form.titlePlaceholder' })}
error={!!errors.title}
disabled={isLoading}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
{/* Read mode and Priority */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{formatMessage({ id: 'specs.form.readMode' })}</Label>
<Select
value={formData.readMode}
onValueChange={(value: SpecReadMode) =>
setFormData((prev) => ({ ...prev, readMode: value }))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{READ_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.labelKey })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{formatMessage({ id: 'specs.form.priority' })}</Label>
<Select
value={formData.priority}
onValueChange={(value: SpecPriority) =>
setFormData((prev) => ({ ...prev, priority: value }))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.labelKey })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Keywords */}
<div className="space-y-2">
<Label htmlFor="keywords">
{formatMessage({ id: 'specs.form.keywords' })}
</Label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
id="keywords"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={handleKeywordKeyDown}
onBlur={addKeyword}
placeholder={formatMessage({ id: 'specs.form.keywordsPlaceholder' })}
disabled={isLoading}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addKeyword}
disabled={isLoading || !keywordInput.trim()}
>
{formatMessage({ id: 'specs.form.addKeyword' })}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'specs.form.keywordsHint' })}
</p>
{/* Keywords display */}
{formData.keywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
{formData.keywords.map((keyword) => (
<Badge
key={keyword}
variant="secondary"
className="text-xs pl-2 pr-1 gap-1"
>
{keyword}
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="ml-0.5 hover:text-destructive transition-colors"
disabled={isLoading}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* File info */}
<div className="pt-2 border-t border-border">
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'specs.form.fileInfo' }, { file: spec.file })}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{formatMessage({ id: 'common.cancel' })}
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading
? formatMessage({ id: 'specs.form.saving' })
: formatMessage({ id: 'common.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default SpecDialog;

View File

@@ -0,0 +1,62 @@
// ========================================
// Specs Components Index
// ========================================
// Export all specs-related components
export {
SpecCard,
SpecCardSkeleton,
} from './SpecCard';
export type {
Spec,
SpecDimension,
SpecReadMode,
SpecPriority,
SpecCardProps,
} from './SpecCard';
export {
SpecDialog,
} from './SpecDialog';
export type {
SpecFormData,
SpecDialogProps,
} from './SpecDialog';
export {
HookCard,
} from './HookCard';
export type {
HookConfig,
HookEvent,
HookScope,
HookFailMode,
HookCardProps,
} from './HookCard';
export {
HookDialog,
} from './HookDialog';
export type {
HookDialogProps,
} from './HookDialog';
export {
InjectionControlTab,
} from './InjectionControlTab';
export type {
InjectionStats,
InjectionSettings,
SpecStatsResponse,
SystemSettingsResponse,
InjectionControlTabProps,
} from './InjectionControlTab';
export {
GlobalSettingsTab,
} from './GlobalSettingsTab';

View File

@@ -139,11 +139,14 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
// Group by status
const issuesByStatus: Record<Issue['status'], Issue[]> = {
open: [],
in_progress: [],
resolved: [],
closed: [],
registered: [],
planning: [],
planned: [],
queued: [],
executing: [],
completed: [],
failed: [],
paused: [],
};
for (const issue of allIssues) {
@@ -184,7 +187,7 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
allIssues,
issuesByStatus,
issuesByPriority,
openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length,
openCount: issuesByStatus.registered.length + issuesByStatus.planning.length + issuesByStatus.planned.length,
criticalCount: issuesByPriority.critical.length,
isLoading: issuesQuery.isLoading,
isFetching: issuesQuery.isFetching || historyQuery.isFetching,

View File

@@ -326,3 +326,140 @@ export function useImportSettings() {
error: mutation.error,
};
}
// ========================================
// Specs Settings Hooks
// ========================================
import {
getSystemSettings,
updateSystemSettings,
installRecommendedHooks,
getSpecStats,
type SystemSettings,
type UpdateSystemSettingsInput,
type InstallRecommendedHooksResponse,
type SpecStats,
} from '../lib/api';
// Query keys for specs settings
export const specsSettingsKeys = {
all: ['specsSettings'] as const,
systemSettings: () => [...specsSettingsKeys.all, 'systemSettings'] as const,
specStats: (projectPath?: string) => [...specsSettingsKeys.all, 'specStats', projectPath] as const,
};
// ========================================
// System Settings Query Hook
// ========================================
export interface UseSystemSettingsReturn {
data: SystemSettings | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook to fetch system settings (injection control, personal spec defaults, recommended hooks)
*/
export function useSystemSettings(): UseSystemSettingsReturn {
const query = useQuery({
queryKey: specsSettingsKeys.systemSettings(),
queryFn: getSystemSettings,
staleTime: STALE_TIME,
retry: 1,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: () => { query.refetch(); },
};
}
// ========================================
// Update System Settings Mutation Hook
// ========================================
export function useUpdateSystemSettings() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateSystemSettingsInput) => updateSystemSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.systemSettings() });
},
});
return {
updateSettings: mutation.mutateAsync,
isPending: mutation.isPending,
error: mutation.error,
};
}
// ========================================
// Install Recommended Hooks Mutation Hook
// ========================================
export function useInstallRecommendedHooks() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ hookIds, scope }: { hookIds: string[]; scope?: 'global' | 'project' }) =>
installRecommendedHooks(hookIds, scope),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.systemSettings() });
},
});
return {
installHooks: (hookIds: string[], scope?: 'global' | 'project') =>
mutation.mutateAsync({ hookIds, scope }),
isPending: mutation.isPending,
error: mutation.error,
data: mutation.data,
};
}
// ========================================
// Spec Stats Query Hook
// ========================================
export interface UseSpecStatsOptions {
projectPath?: string;
enabled?: boolean;
staleTime?: number;
}
export interface UseSpecStatsReturn {
data: SpecStats | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook to fetch spec statistics (dimensions count, injection length info)
* @param options - Options including projectPath for workspace isolation
*/
export function useSpecStats(options: UseSpecStatsOptions = {}): UseSpecStatsReturn {
const { projectPath, enabled = true, staleTime = STALE_TIME } = options;
const query = useQuery({
queryKey: specsSettingsKeys.specStats(projectPath),
queryFn: () => getSpecStats(projectPath),
staleTime,
enabled,
retry: 1,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: () => { query.refetch(); },
};
}

View File

@@ -728,13 +728,26 @@ export interface Issue {
id: string;
title: string;
context?: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'completed';
status: 'registered' | 'planning' | 'planned' | 'queued' | 'executing' | 'completed' | 'failed' | 'paused';
priority: 'low' | 'medium' | 'high' | 'critical';
createdAt: string;
updatedAt?: string;
plannedAt?: string;
queuedAt?: string;
completedAt?: string;
solutions?: IssueSolution[];
labels?: string[];
assignee?: string;
tags?: string[];
source?: 'github' | 'text' | 'discovery';
sourceUrl?: string;
boundSolutionId?: string | null;
feedback?: Array<{
type: 'failure' | 'clarification' | 'rejection';
stage: string;
content: string;
createdAt: string;
}>;
attachments?: Attachment[];
}
@@ -7133,6 +7146,121 @@ export async function triggerReindex(
);
}
// ========== System Settings API ==========
/**
* System settings response from /api/system/settings
*/
export interface SystemSettings {
injectionControl: {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
};
personalSpecDefaults: {
defaultReadMode: 'required' | 'optional' | 'keywords';
autoEnable: boolean;
};
recommendedHooks: Array<{
id: string;
event: string;
name: string;
command: string;
description: string;
scope: 'global' | 'project';
autoInstall: boolean;
}>;
}
/**
* Update system settings request
*/
export interface UpdateSystemSettingsInput {
injectionControl?: Partial<SystemSettings['injectionControl']>;
personalSpecDefaults?: Partial<SystemSettings['personalSpecDefaults']>;
}
/**
* Install recommended hooks request
*/
export interface InstallRecommendedHooksInput {
hookIds: string[];
scope?: 'global' | 'project';
}
/**
* Installed hook result
*/
export interface InstalledHook {
id: string;
event: string;
status: 'installed' | 'already-exists';
}
/**
* Install recommended hooks response
*/
export interface InstallRecommendedHooksResponse {
success: boolean;
installed: InstalledHook[];
}
/**
* Fetch system settings (injection control, personal spec defaults, recommended hooks)
*/
export async function getSystemSettings(): Promise<SystemSettings> {
return fetchApi<SystemSettings>('/api/system/settings');
}
/**
* Update system settings
*/
export async function updateSystemSettings(data: UpdateSystemSettingsInput): Promise<{ success: boolean; settings?: Record<string, unknown> }> {
return fetchApi<{ success: boolean; settings?: Record<string, unknown> }>('/api/system/settings', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Install recommended hooks
*/
export async function installRecommendedHooks(
hookIds: string[],
scope?: 'global' | 'project'
): Promise<InstallRecommendedHooksResponse> {
return fetchApi<InstallRecommendedHooksResponse>('/api/system/hooks/install-recommended', {
method: 'POST',
body: JSON.stringify({ hookIds, scope } as InstallRecommendedHooksInput),
});
}
// ========== Spec Stats API ==========
/**
* Spec stats response from /api/specs/stats
*/
export interface SpecStats {
dimensions: Record<string, { count: number; requiredCount: number }>;
injectionLength: {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
};
}
/**
* Fetch spec statistics for a specific workspace
* @param projectPath - Optional project path to filter data by workspace
*/
export async function getSpecStats(projectPath?: string): Promise<SpecStats> {
const url = projectPath
? `/api/specs/stats?path=${encodeURIComponent(projectPath)}`
: '/api/specs/stats';
return fetchApi<SpecStats>(url);
}
// ========== Analysis API ==========
import type { AnalysisSessionSummary, AnalysisSessionDetail } from '../types/analysis';

View File

@@ -38,7 +38,8 @@
"teams": "Team Execution",
"terminalDashboard": "Terminal Dashboard",
"skillHub": "Skill Hub",
"analysis": "Analysis Viewer"
"analysis": "Analysis Viewer",
"specs": "Spec Settings"
},
"sidebar": {
"collapse": "Collapse",

View File

@@ -38,7 +38,8 @@
"teams": "团队执行",
"terminalDashboard": "终端仪表板",
"skillHub": "技能中心",
"analysis": "分析查看器"
"analysis": "分析查看器",
"specs": "规范设置"
},
"sidebar": {
"collapse": "收起",

View File

@@ -17,9 +17,8 @@ import {
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
const VALID_TABS: IssueTab[] = ['issues', 'board', 'queue', 'discovery'];
const VALID_TABS: IssueTab[] = ['issues', 'queue', 'discovery'];
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
// ExecutionPanel hidden - import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
@@ -366,7 +365,6 @@ export function IssueHubPage() {
const renderActionButtons = () => {
switch (currentTab) {
case 'issues':
case 'board':
return (
<>
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
@@ -437,7 +435,6 @@ export function IssueHubPage() {
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
{currentTab === 'board' && <IssueBoardPanel />}
{currentTab === 'queue' && <QueuePanel />}
{currentTab === 'discovery' && <DiscoveryPanel />}

View File

@@ -0,0 +1,353 @@
/**
* Specs Settings Page
*
* Main page for managing spec settings, hooks, injection control, and global settings.
* Uses 5 tabs: Project Specs | Personal Specs | Hooks | Injection | Settings
*/
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollText, User, Plug, Gauge, Settings, RefreshCw, Search } from 'lucide-react';
import { SpecCard, SpecDialog, type Spec, type SpecFormData } from '@/components/specs';
import { HookCard, HookDialog, type HookConfig } from '@/components/specs';
import { InjectionControlTab } from '@/components/specs/InjectionControlTab';
import { GlobalSettingsTab } from '@/components/specs/GlobalSettingsTab';
import { useSpecStats } from '@/hooks/useSystemSettings';
type SettingsTab = 'project-specs' | 'personal-specs' | 'hooks' | 'injection' | 'settings';
export function SpecsSettingsPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<SettingsTab>('project-specs');
const [searchQuery, setSearchQuery] = useState('');
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [hookDialogOpen, setHookDialogOpen] = useState(false);
const [editingSpec, setEditingSpec] = useState<Spec | null>(null);
const [editingHook, setEditingHook] = useState<HookConfig | null>(null);
// Mock data for demonstration - will be replaced with real API calls
const [projectSpecs] = useState<Spec[]>([]);
const [personalSpecs] = useState<Spec[]>([]);
const [hooks] = useState<HookConfig[]>([]);
const [isLoading] = useState(false);
const { data: statsData, refetch: refetchStats } = useSpecStats();
const handleSpecEdit = (spec: Spec) => {
setEditingSpec(spec);
setEditDialogOpen(true);
};
const handleSpecSave = async (specId: string, data: SpecFormData) => {
console.log('Saving spec:', specId, data);
// TODO: Implement save logic
setEditDialogOpen(false);
};
const handleSpecToggle = async (specId: string, enabled: boolean) => {
console.log('Toggling spec:', specId, enabled);
// TODO: Implement toggle logic
};
const handleSpecDelete = async (specId: string) => {
console.log('Deleting spec:', specId);
// TODO: Implement delete logic
};
const handleHookEdit = (hook: HookConfig) => {
setEditingHook(hook);
setHookDialogOpen(true);
};
const handleHookSave = async (hookId: string | null, data: Partial<HookConfig>) => {
console.log('Saving hook:', hookId, data);
// TODO: Implement save logic
setHookDialogOpen(false);
};
const handleHookToggle = async (hookId: string, enabled: boolean) => {
console.log('Toggling hook:', hookId, enabled);
// TODO: Implement toggle logic
};
const handleHookDelete = async (hookId: string) => {
console.log('Deleting hook:', hookId);
// TODO: Implement delete logic
};
const handleRebuildIndex = async () => {
console.log('Rebuilding index...');
// TODO: Implement rebuild logic
};
const filterSpecs = (specs: Spec[]) => {
if (!searchQuery.trim()) return specs;
const query = searchQuery.toLowerCase();
return specs.filter(spec =>
spec.title.toLowerCase().includes(query) ||
spec.keywords.some(k => k.toLowerCase().includes(query))
);
};
const renderSpecsTab = (dimension: 'project' | 'personal') => {
const specs = dimension === 'project' ? projectSpecs : personalSpecs;
const filteredSpecs = filterSpecs(specs);
return (
<div className="space-y-4">
{/* Search and Actions */}
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'specs.searchPlaceholder', defaultMessage: 'Search specs...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={handleRebuildIndex}>
<RefreshCw className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.rebuildIndex', defaultMessage: 'Rebuild Index' })}
</Button>
</div>
{/* Stats Summary */}
{statsData && (
<div className="grid grid-cols-4 gap-4">
{Object.entries(statsData.dimensions).map(([dim, data]) => (
<Card key={dim}>
<CardContent className="pt-4">
<div className="text-sm text-muted-foreground capitalize">{dim}</div>
<div className="text-2xl font-bold">{(data as { count: number }).count}</div>
<div className="text-xs text-muted-foreground">
{(data as { requiredCount: number }).requiredCount} required
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Specs Grid */}
{filteredSpecs.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{isLoading
? formatMessage({ id: 'specs.loading', defaultMessage: 'Loading specs...' })
: formatMessage({ id: 'specs.noSpecs', defaultMessage: 'No specs found. Create specs in .workflow/ directory.' })
}
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredSpecs.map(spec => (
<SpecCard
key={spec.id}
spec={spec}
onEdit={handleSpecEdit}
onToggle={handleSpecToggle}
onDelete={handleSpecDelete}
/>
))}
</div>
)}
</div>
);
};
const renderHooksTab = () => {
const filteredHooks = hooks.filter(hook => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return hook.name.toLowerCase().includes(query) ||
hook.event.toLowerCase().includes(query);
});
// Recommended hooks
const recommendedHooks: HookConfig[] = [
{
id: 'spec-injection-session',
name: 'Spec Context Injection (Session)',
event: 'SessionStart',
command: 'ccw spec load --stdin',
scope: 'global',
enabled: true,
timeout: 5000,
failMode: 'silent'
},
{
id: 'spec-injection-prompt',
name: 'Spec Context Injection (Prompt)',
event: 'UserPromptSubmit',
command: 'ccw spec load --stdin',
scope: 'project',
enabled: true,
timeout: 5000,
failMode: 'silent'
}
];
return (
<div className="space-y-6">
{/* Recommended Hooks Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="h-5 w-5" />
{formatMessage({ id: 'specs.recommendedHooks', defaultMessage: 'Recommended Hooks' })}
</CardTitle>
<CardDescription>
{formatMessage({ id: 'specs.recommendedHooksDesc', defaultMessage: 'One-click install system-preset spec injection hooks' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-4">
<Button onClick={() => console.log('Install all')}>
{formatMessage({ id: 'specs.installAll', defaultMessage: 'Install All Recommended Hooks' })}
</Button>
<div className="text-sm text-muted-foreground flex items-center">
{hooks.filter(h => recommendedHooks.some(r => r.command === h.command)).length} / {recommendedHooks.length} installed
</div>
</div>
<div className="grid gap-3">
{recommendedHooks.map(hook => (
<HookCard
key={hook.id}
hook={hook}
isRecommended={true}
onInstall={() => console.log('Install:', hook.id)}
onEdit={handleHookEdit}
onToggle={handleHookToggle}
onDelete={handleHookDelete}
/>
))}
</div>
</CardContent>
</Card>
{/* Installed Hooks Section */}
<Card>
<CardHeader>
<CardTitle>{formatMessage({ id: 'specs.installedHooks', defaultMessage: 'Installed Hooks' })}</CardTitle>
<CardDescription>
{formatMessage({ id: 'specs.installedHooksDesc', defaultMessage: 'Manage your installed hooks configuration' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'specs.searchHooks', defaultMessage: 'Search hooks...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{filteredHooks.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{formatMessage({ id: 'specs.noHooks', defaultMessage: 'No hooks installed. Install recommended hooks above.' })}
</div>
) : (
<div className="grid gap-3">
{filteredHooks.map(hook => (
<HookCard
key={hook.id}
hook={hook}
onEdit={handleHookEdit}
onToggle={handleHookToggle}
onDelete={handleHookDelete}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};
return (
<div className="container py-6 max-w-6xl">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<ScrollText className="h-6 w-6" />
{formatMessage({ id: 'specs.pageTitle', defaultMessage: 'Spec Settings' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'specs.pageDescription', defaultMessage: 'Manage specification injection, hooks, and system settings' })}
</p>
</div>
{/* Main Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as SettingsTab)}>
<TabsList className="grid grid-cols-5 mb-6">
<TabsTrigger value="project-specs" className="flex items-center gap-2">
<ScrollText className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabProjectSpecs', defaultMessage: 'Project Specs' })}</span>
</TabsTrigger>
<TabsTrigger value="personal-specs" className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabPersonalSpecs', defaultMessage: 'Personal' })}</span>
</TabsTrigger>
<TabsTrigger value="hooks" className="flex items-center gap-2">
<Plug className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabHooks', defaultMessage: 'Hooks' })}</span>
</TabsTrigger>
<TabsTrigger value="injection" className="flex items-center gap-2">
<Gauge className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabInjection', defaultMessage: 'Injection' })}</span>
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabSettings', defaultMessage: 'Settings' })}</span>
</TabsTrigger>
</TabsList>
<TabsContent value="project-specs">
{renderSpecsTab('project')}
</TabsContent>
<TabsContent value="personal-specs">
{renderSpecsTab('personal')}
</TabsContent>
<TabsContent value="hooks">
{renderHooksTab()}
</TabsContent>
<TabsContent value="injection">
<InjectionControlTab />
</TabsContent>
<TabsContent value="settings">
<GlobalSettingsTab />
</TabsContent>
</Tabs>
{/* Edit Spec Dialog */}
<SpecDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
spec={editingSpec}
onSave={handleSpecSave}
/>
{/* Edit Hook Dialog */}
<HookDialog
open={hookDialogOpen}
onOpenChange={setHookDialogOpen}
hook={editingHook}
onSave={handleHookSave}
/>
</div>
);
}
export default SpecsSettingsPage;

View File

@@ -38,3 +38,4 @@ export { TeamPage } from './TeamPage';
export { TerminalDashboardPage } from './TerminalDashboardPage';
export { SkillHubPage } from './SkillHubPage';
export { AnalysisPage } from './AnalysisPage';
export { SpecsSettingsPage } from './SpecsSettingsPage';

View File

@@ -37,6 +37,7 @@ import {
TeamPage,
TerminalDashboardPage,
AnalysisPage,
SpecsSettingsPage,
} from '@/pages';
/**
@@ -146,6 +147,10 @@ const routes: RouteObject[] = [
path: 'settings/rules',
element: <RulesManagerPage />,
},
{
path: 'settings/specs',
element: <SpecsSettingsPage />,
},
{
path: 'settings/codexlens',
element: <CodexLensManagerPage />,

View File

@@ -716,7 +716,7 @@ async function notifyAction(options: HookOptions): Promise<void> {
}
/**
* Project state action - reads project-tech.json and project-guidelines.json
* Project state action - reads project-tech.json and specs
* and outputs a concise summary for session context injection.
*
* Used as SessionStart hook: stdout → injected as system message.
@@ -767,31 +767,19 @@ async function projectStateAction(options: HookOptions): Promise<void> {
} catch { /* ignore parse errors */ }
}
// Read project-guidelines.json
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
if (existsSync(guidelinesPath)) {
try {
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
// constraints is Record<string, array> - flatten all categories
const allConstraints: string[] = [];
if (gl.constraints && typeof gl.constraints === 'object') {
for (const entries of Object.values(gl.constraints)) {
if (Array.isArray(entries)) {
for (const c of entries) {
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
}
}
}
// Read specs from spec system (ccw spec load --dimension specs)
try {
const { getDimensionIndex } = await import('../tools/spec-index-builder.js');
const specsIndex = await getDimensionIndex(projectPath, 'specs');
const constraints: string[] = [];
for (const entry of specsIndex.entries) {
if (entry.readMode === 'required') {
constraints.push(entry.title);
}
result.guidelines.constraints = allConstraints.slice(0, limit);
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
result.guidelines.recent_learnings = learnings.slice(0, limit).map(
(l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' })
);
} catch { /* ignore parse errors */ }
}
}
result.guidelines.constraints = constraints.slice(0, limit);
result.guidelines.recent_learnings = [];
} catch { /* ignore errors */ }
if (stdin) {
// Format as <project-state> tag for system message injection

View File

@@ -205,7 +205,7 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s
join(workflowDir, 'active'),
join(workflowDir, 'archives'),
join(workflowDir, 'project-tech.json'),
join(workflowDir, 'project-guidelines.json'),
join(workflowDir, 'specs'),
...sessions.active.map(s => s.path),
...sessions.archived.map(s => s.path)
];
@@ -564,14 +564,12 @@ function sortTaskIds(a: string, b: string): number {
}
/**
* Load project overview from project-tech.json and project-guidelines.json
* Load project overview from project-tech.json
* @param workflowDir - Path to .workflow directory
* @returns Project overview data or null if not found
*/
export function loadProjectOverview(workflowDir: string): ProjectOverview | null {
const techFile = join(workflowDir, 'project-tech.json');
const guidelinesFile = join(workflowDir, 'project-guidelines.json');
if (!existsSync(techFile)) {
console.log(`Project file not found at: ${techFile}`);
return null;
@@ -607,44 +605,9 @@ export function loadProjectOverview(workflowDir: string): ProjectOverview | null
});
};
// Load guidelines from separate file if exists
let guidelines: ProjectGuidelines | null = null;
if (existsSync(guidelinesFile)) {
try {
const guidelinesContent = readFileSync(guidelinesFile, 'utf8');
const guidelinesData = JSON.parse(guidelinesContent) as Record<string, unknown>;
const conventions = guidelinesData.conventions as Record<string, string[]> | undefined;
const constraints = guidelinesData.constraints as Record<string, string[]> | undefined;
guidelines = {
conventions: {
coding_style: conventions?.coding_style || [],
naming_patterns: conventions?.naming_patterns || [],
file_structure: conventions?.file_structure || [],
documentation: conventions?.documentation || []
},
constraints: {
architecture: constraints?.architecture || [],
tech_stack: constraints?.tech_stack || [],
performance: constraints?.performance || [],
security: constraints?.security || []
},
quality_rules: (guidelinesData.quality_rules as Array<{ rule: string; scope: string; enforced_by?: string }>) || [],
learnings: (guidelinesData.learnings as Array<{
date: string;
session_id?: string;
insight: string;
context?: string;
category?: string;
}>) || [],
_metadata: guidelinesData._metadata as ProjectGuidelines['_metadata'] | undefined
};
console.log(`Successfully loaded project guidelines`);
} catch (guidelinesErr) {
console.error(`Failed to parse project-guidelines.json:`, (guidelinesErr as Error).message);
}
}
// Guidelines now managed by spec system (ccw spec load)
// Return null - dashboard doesn't need guidelines data directly
const guidelines: ProjectGuidelines | null = null;
return {
projectName: (projectData.project_name as string) || 'Unknown',

View File

@@ -7,7 +7,6 @@ import { listTools } from '../../tools/index.js';
import { loadProjectOverview } from '../data-aggregator.js';
import { resolvePath } from '../../utils/path-resolver.js';
import { join } from 'path';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { RouteContext } from './types.js';
/**
@@ -46,74 +45,23 @@ export async function handleCcwRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Get Project Guidelines
// API: Get Project Guidelines (DEPRECATED - use spec system)
if (pathname === '/api/ccw/guidelines' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
if (!existsSync(guidelinesFile)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ guidelines: null }));
return true;
}
try {
const content = readFileSync(guidelinesFile, 'utf-8');
const guidelines = JSON.parse(content);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ guidelines }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read guidelines file' }));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
deprecated: true,
message: 'Use /api/specs/list instead. Guidelines are now managed by the spec system (ccw spec).'
}));
return true;
}
// API: Update Project Guidelines
// API: Update Project Guidelines (DEPRECATED - use spec system)
if (pathname === '/api/ccw/guidelines' && req.method === 'PUT') {
handlePostRequest(req, res, async (body) => {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
try {
const data = body as Record<string, unknown>;
// Read existing file to preserve _metadata.created_at
let existingMetadata: Record<string, unknown> = {};
if (existsSync(guidelinesFile)) {
try {
const existing = JSON.parse(readFileSync(guidelinesFile, 'utf-8'));
existingMetadata = existing._metadata || {};
} catch { /* ignore parse errors */ }
}
// Build the guidelines object
const guidelines = {
conventions: data.conventions || { coding_style: [], naming_patterns: [], file_structure: [], documentation: [] },
constraints: data.constraints || { architecture: [], tech_stack: [], performance: [], security: [] },
quality_rules: data.quality_rules || [],
learnings: data.learnings || [],
_metadata: {
created_at: (existingMetadata.created_at as string) || new Date().toISOString(),
updated_at: new Date().toISOString(),
version: (existingMetadata.version as string) || '1.0.0',
},
};
writeFileSync(guidelinesFile, JSON.stringify(guidelines, null, 2), 'utf-8');
broadcastToClients({
type: 'PROJECT_GUIDELINES_UPDATED',
payload: { timestamp: new Date().toISOString() },
});
return { success: true, guidelines };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
deprecated: true,
message: 'Use /api/specs/update-frontmatter instead. Guidelines are now managed by the spec system (ccw spec).'
}));
return true;
}

View File

@@ -548,29 +548,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
} catch { /* ignore parse errors */ }
}
// Read project-guidelines.json
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
if (existsSync(guidelinesPath)) {
try {
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
const g = result.guidelines as Record<string, unknown>;
// constraints is Record<string, array> - flatten all categories
const allConstraints: string[] = [];
if (gl.constraints && typeof gl.constraints === 'object') {
for (const entries of Object.values(gl.constraints)) {
if (Array.isArray(entries)) {
for (const c of entries) {
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
}
}
}
// Read specs from spec system (ccw spec load --dimension specs)
try {
const { getDimensionIndex } = await import('../../tools/spec-index-builder.js');
const specsIndex = await getDimensionIndex(projectPath, 'specs');
const g = result.guidelines as Record<string, unknown>;
const constraints: string[] = [];
for (const entry of specsIndex.entries) {
if (entry.readMode === 'required') {
constraints.push(entry.title);
}
g.constraints = allConstraints.slice(0, limit);
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
g.recent_learnings = learnings.slice(0, limit).map((l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' }));
} catch { /* ignore parse errors */ }
}
}
g.constraints = constraints.slice(0, limit);
g.recent_learnings = [];
} catch { /* ignore errors */ }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));

View File

@@ -0,0 +1,232 @@
/**
* Spec Routes Module
* Handles all spec management API endpoints
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { resolvePath } from '../../utils/path-resolver.js';
import type { RouteContext } from './types.js';
/**
* Handle Spec routes
* @returns true if route was handled, false otherwise
*/
export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
// API: List all specs from index
if (pathname === '/api/specs/list' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
try {
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
'../../tools/spec-index-builder.js'
);
const result: Record<string, unknown[]> = {};
for (const dim of SPEC_DIMENSIONS) {
const index = await getDimensionIndex(resolvedPath, dim);
result[dim] = index.entries;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ specs: result }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Get spec detail (MD content)
if (pathname === '/api/specs/detail' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const file = url.searchParams.get('file');
if (!file) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing file parameter' }));
return true;
}
const filePath = join(resolvedPath, file);
if (!existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
try {
const content = readFileSync(filePath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ content }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Update frontmatter (toggle readMode)
if (pathname === '/api/specs/update-frontmatter' && req.method === 'PUT') {
handlePostRequest(req, res, async (body) => {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const data = body as { file: string; readMode: string };
if (!data.file || !data.readMode) {
return { error: 'Missing file or readMode', status: 400 };
}
const filePath = join(resolvedPath, data.file);
if (!existsSync(filePath)) {
return { error: 'File not found', status: 404 };
}
try {
const matter = (await import('gray-matter')).default;
const raw = readFileSync(filePath, 'utf-8');
const parsed = matter(raw);
parsed.data.readMode = data.readMode;
const updated = matter.stringify(parsed.content, parsed.data);
writeFileSync(filePath, updated, 'utf-8');
return { success: true, readMode: data.readMode };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// API: Rebuild index
if (pathname === '/api/specs/rebuild' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
try {
const { buildAllIndices, readCachedIndex, SPEC_DIMENSIONS } = await import(
'../../tools/spec-index-builder.js'
);
await buildAllIndices(resolvedPath);
const stats: Record<string, number> = {};
for (const dim of SPEC_DIMENSIONS) {
const cached = readCachedIndex(resolvedPath, dim);
stats[dim] = cached?.entries.length ?? 0;
}
return { success: true, stats };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// API: Init spec system
if (pathname === '/api/specs/init' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
try {
const { initSpecSystem } = await import('../../tools/spec-init.js');
const result = initSpecSystem(resolvedPath);
return { success: true, ...result };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// API: Get spec stats (dimensions count + injection length info)
if (pathname === '/api/specs/stats' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
try {
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
'../../tools/spec-index-builder.js'
);
// Get maxLength from system settings
let maxLength = 8000;
const settingsPath = join(homedir(), '.claude', 'settings.json');
if (existsSync(settingsPath)) {
try {
const rawSettings = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(rawSettings) as {
system?: { injectionControl?: { maxLength?: number } };
};
maxLength = settings?.system?.injectionControl?.maxLength || 8000;
} catch { /* ignore */ }
}
const dimensions: Record<string, { count: number; requiredCount: number }> = {};
let totalRequiredLength = 0;
let totalWithKeywords = 0;
for (const dim of SPEC_DIMENSIONS) {
const index = await getDimensionIndex(resolvedPath, dim);
let count = 0;
let requiredCount = 0;
for (const entry of index.entries) {
count++;
// Calculate content length by reading the file
const filePath = join(resolvedPath, entry.file);
let contentLength = 0;
try {
if (existsSync(filePath)) {
const rawContent = readFileSync(filePath, 'utf-8');
// Strip frontmatter to get actual content length
const matter = (await import('gray-matter')).default;
const parsed = matter(rawContent);
contentLength = parsed.content.length;
}
} catch { /* ignore */ }
if (entry.readMode === 'required') {
requiredCount++;
totalRequiredLength += contentLength;
}
totalWithKeywords += contentLength;
}
dimensions[dim] = { count, requiredCount };
}
const percentage = totalWithKeywords > 0 ? Math.round((totalWithKeywords / maxLength) * 100) : 0;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
dimensions,
injectionLength: {
requiredOnly: totalRequiredLength,
withKeywords: totalWithKeywords,
maxLength,
percentage
}
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
return false;
}

View File

@@ -3,8 +3,9 @@
* Handles all system-related API endpoints
*/
import type { Server } from 'http';
import { readFileSync, existsSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { readFileSync, writeFileSync, existsSync, mkdirSync, promises as fsPromises } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay } from '../../utils/path-resolver.js';
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
import { scanSessions } from '../session-scanner.js';
@@ -24,6 +25,196 @@ interface SystemRouteContext extends RouteContext {
server: Server;
}
// ========================================
// System Settings Helper Functions
// ========================================
const GLOBAL_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
// Default system settings
const DEFAULT_INJECTION_CONTROL = {
maxLength: 8000,
warnThreshold: 6000,
truncateOnExceed: true
};
const DEFAULT_PERSONAL_SPEC_DEFAULTS = {
defaultReadMode: 'optional',
autoEnable: true
};
// Recommended hooks for spec injection
const RECOMMENDED_HOOKS = [
{
id: 'spec-injection-session',
event: 'SessionStart',
name: 'Spec Context Injection (Session)',
command: 'ccw spec load --stdin',
description: 'Session开始时注入规范上下文',
scope: 'global',
autoInstall: true
},
{
id: 'spec-injection-prompt',
event: 'UserPromptSubmit',
name: 'Spec Context Injection (Prompt)',
command: 'ccw spec load --stdin',
description: '提示词触发时注入规范上下文',
scope: 'project',
autoInstall: true
}
];
/**
* Read settings file safely
*/
function readSettingsFile(filePath: string): Record<string, unknown> {
try {
if (!existsSync(filePath)) {
return {};
}
const content = readFileSync(filePath, 'utf8');
if (!content.trim()) {
return {};
}
return JSON.parse(content);
} catch (error: unknown) {
console.error(`Error reading settings file ${filePath}:`, error);
return {};
}
}
/**
* Get system settings from global settings file
*/
function getSystemSettings(): {
injectionControl: typeof DEFAULT_INJECTION_CONTROL;
personalSpecDefaults: typeof DEFAULT_PERSONAL_SPEC_DEFAULTS;
recommendedHooks: typeof RECOMMENDED_HOOKS;
} {
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
const system = (settings.system || {}) as Record<string, unknown>;
const user = (settings.user || {}) as Record<string, unknown>;
return {
injectionControl: {
...DEFAULT_INJECTION_CONTROL,
...((system.injectionControl || {}) as Record<string, unknown>)
} as typeof DEFAULT_INJECTION_CONTROL,
personalSpecDefaults: {
...DEFAULT_PERSONAL_SPEC_DEFAULTS,
...((user.personalSpecDefaults || {}) as Record<string, unknown>)
} as typeof DEFAULT_PERSONAL_SPEC_DEFAULTS,
recommendedHooks: RECOMMENDED_HOOKS
};
}
/**
* Save system settings to global settings file
*/
function saveSystemSettings(updates: {
injectionControl?: Partial<typeof DEFAULT_INJECTION_CONTROL>;
personalSpecDefaults?: Partial<typeof DEFAULT_PERSONAL_SPEC_DEFAULTS>;
}): { success: boolean; settings?: Record<string, unknown>; error?: string } {
try {
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
// Initialize nested objects if needed
if (!settings.system) settings.system = {};
if (!settings.user) settings.user = {};
const system = settings.system as Record<string, unknown>;
const user = settings.user as Record<string, unknown>;
// Apply updates
if (updates.injectionControl) {
system.injectionControl = {
...DEFAULT_INJECTION_CONTROL,
...((system.injectionControl || {}) as Record<string, unknown>),
...updates.injectionControl
};
}
if (updates.personalSpecDefaults) {
user.personalSpecDefaults = {
...DEFAULT_PERSONAL_SPEC_DEFAULTS,
...((user.personalSpecDefaults || {}) as Record<string, unknown>),
...updates.personalSpecDefaults
};
}
// Ensure directory exists
const dirPath = dirname(GLOBAL_SETTINGS_PATH);
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
return { success: true, settings };
} catch (error: unknown) {
console.error('Error saving system settings:', error);
return { success: false, error: (error as Error).message };
}
}
/**
* Install a recommended hook to settings
*/
function installRecommendedHook(
hookId: string,
scope: 'global' | 'project'
): { success: boolean; installed?: Record<string, unknown>; error?: string; status?: string } {
const hook = RECOMMENDED_HOOKS.find(h => h.id === hookId);
if (!hook) {
return { success: false, error: 'Hook not found', status: 'not-found' };
}
try {
const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : join(process.cwd(), '.claude', 'settings.json');
const settings = readSettingsFile(filePath) as Record<string, unknown> & { hooks?: Record<string, unknown[]> };
// Initialize hooks object if needed
if (!settings.hooks) settings.hooks = {};
const event = hook.event;
if (!settings.hooks[event]) {
settings.hooks[event] = [];
}
// Check if hook already exists (by command)
const existingHooks = (settings.hooks[event] || []) as Array<Record<string, unknown>>;
const existingIndex = existingHooks.findIndex(
(h) => (h as Record<string, unknown>).command === hook.command
);
if (existingIndex >= 0) {
return { success: true, installed: { id: hookId, event, status: 'already-exists' } };
}
// Add new hook
settings.hooks[event].push({
name: hook.name,
command: hook.command,
timeout: 5000,
failMode: 'silent'
});
// Ensure directory exists
const dirPath = dirname(filePath);
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8');
return { success: true, installed: { id: hookId, event, status: 'installed' } };
} catch (error: unknown) {
console.error('Error installing hook:', error);
return { success: false, error: (error as Error).message };
}
}
// ========================================
// Helper Functions
// ========================================
@@ -196,6 +387,67 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// ========================================
// System Settings API Endpoints
// ========================================
// API: Get system settings (injection control + personal spec defaults + recommended hooks)
if (pathname === '/api/system/settings' && req.method === 'GET') {
try {
const settings = getSystemSettings();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(settings));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Save system settings
if (pathname === '/api/system/settings' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const updates = body as {
injectionControl?: { maxLength?: number; warnThreshold?: number; truncateOnExceed?: boolean };
personalSpecDefaults?: { defaultReadMode?: string; autoEnable?: boolean };
};
const result = saveSystemSettings(updates);
if (result.error) {
return { error: result.error, status: 500 };
}
return { success: true, settings: result.settings };
});
return true;
}
// API: Install recommended hooks
if (pathname === '/api/system/hooks/install-recommended' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { hookIds, scope } = body as {
hookIds?: string[];
scope?: 'global' | 'project';
};
if (!hookIds || !Array.isArray(hookIds)) {
return { error: 'hookIds array is required', status: 400 };
}
const targetScope = scope || 'global';
const installed: Array<{ id: string; event: string; status: string }> = [];
for (const hookId of hookIds) {
const result = installRecommendedHook(hookId, targetScope);
if (result.success && result.installed) {
installed.push(result.installed as { id: string; event: string; status: string });
}
}
return { success: true, installed };
});
return true;
}
// API: Get recent paths
if (pathname === '/api/recent-paths') {
const paths = getRecentPaths();

View File

@@ -41,6 +41,7 @@ import { handleConfigRoutes } from './routes/config-routes.js';
import { handleTeamRoutes } from './routes/team-routes.js';
import { handleNotificationRoutes } from './routes/notification-routes.js';
import { handleAnalysisRoutes } from './routes/analysis-routes.js';
import { handleSpecRoutes } from './routes/spec-routes.js';
// Import WebSocket handling
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
@@ -523,6 +524,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleGraphRoutes(routeContext)) return;
}
// Spec routes (/api/specs/*)
if (pathname.startsWith('/api/specs/')) {
if (await handleSpecRoutes(routeContext)) return;
}
// CCW routes (/api/ccw and /api/ccw/*)
if (pathname.startsWith('/api/ccw')) {
if (await handleCcwRoutes(routeContext)) return;

View File

@@ -267,8 +267,8 @@ export function initSpecSystem(projectPath: string): InitResult {
}
}
// Create index directory
const indexPath = join(workflowDir, INDEX_DIR);
// Create index directory at project root (matches spec-index-builder.ts location)
const indexPath = join(projectPath, INDEX_DIR);
if (!existsSync(indexPath)) {
mkdirSync(indexPath, { recursive: true });
result.directories.push(indexPath);

View File

@@ -15,6 +15,7 @@
import matter from 'gray-matter';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import {
getDimensionIndex,
@@ -49,6 +50,10 @@ export interface SpecLoadOptions {
stdinData?: { user_prompt?: string; prompt?: string; [key: string]: unknown };
/** Enable debug logging to stderr */
debug?: boolean;
/** Maximum content length in characters (default: 8000) */
maxLength?: number;
/** Whether to truncate content if it exceeds maxLength (default: true) */
truncateOnExceed?: boolean;
}
/**
@@ -63,6 +68,19 @@ export interface SpecLoadResult {
matchedSpecs: string[];
/** Total number of spec files loaded */
totalLoaded: number;
/** Content length statistics */
contentLength: {
/** Original content length before truncation */
original: number;
/** Final content length (after truncation if applied) */
final: number;
/** Maximum allowed length */
maxLength: number;
/** Whether content was truncated */
truncated: boolean;
/** Percentage of max length used */
percentage: number;
};
}
/**
@@ -114,7 +132,8 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
* 3. Filter: all required specs + optional specs with keyword match
* 4. Load MD file content (strip frontmatter)
* 5. Merge by dimension priority
* 6. Format for CLI (markdown) or Hook (JSON)
* 6. Check length and truncate if needed
* 7. Format for CLI (markdown) or Hook (JSON)
*
* @param options - Loading configuration
* @returns SpecLoadResult with formatted content
@@ -122,6 +141,10 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResult> {
const { projectPath, outputFormat, debug } = options;
// Get injection control settings
const maxLength = options.maxLength ?? 8000;
const truncateOnExceed = options.truncateOnExceed ?? true;
// Step 1: Resolve keywords
const keywords = resolveKeywords(options);
@@ -165,16 +188,40 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
// Step 5: Merge by dimension priority
const mergedContent = mergeByPriority(allLoadedSpecs);
// Step 6: Format output
// Step 6: Check length and truncate if needed
const originalLength = mergedContent.length;
let finalContent = mergedContent;
let truncated = false;
if (originalLength > maxLength && truncateOnExceed) {
// Truncate content, preserving complete sections where possible
finalContent = truncateContent(mergedContent, maxLength);
truncated = true;
if (debug) {
debugLog(`Content truncated: ${originalLength} -> ${finalContent.length} (max: ${maxLength})`);
}
}
// Step 7: Format output
const matchedTitles = allLoadedSpecs.map(s => s.title);
const content = formatOutput(mergedContent, matchedTitles, outputFormat);
const content = formatOutput(finalContent, matchedTitles, outputFormat);
const format = outputFormat === 'cli' ? 'markdown' : 'json';
const percentage = Math.round((originalLength / maxLength) * 100);
return {
content,
format,
matchedSpecs: matchedTitles,
totalLoaded: allLoadedSpecs.length,
contentLength: {
original: originalLength,
final: finalContent.length,
maxLength,
truncated,
percentage: Math.min(percentage, 100),
},
};
}
@@ -376,3 +423,37 @@ function formatOutput(
function debugLog(message: string): void {
process.stderr.write(`[spec-loader] ${message}\n`);
}
/**
* Truncate content to fit within maxLength while preserving complete sections.
*
* Strategy: Remove sections from the end (lowest priority) until within limit.
* Each section is delimited by '\n\n---\n\n' from mergeByPriority.
*
* @param content - Full merged content
* @param maxLength - Maximum allowed length
* @returns Truncated content string
*/
function truncateContent(content: string, maxLength: number): string {
if (content.length <= maxLength) {
return content;
}
// Split by section separator
const sections = content.split('\n\n---\n\n');
// Remove sections from the end until we're within limit
while (sections.length > 1) {
sections.pop();
const newContent = sections.join('\n\n---\n\n');
if (newContent.length <= maxLength) {
// Add truncation notice
return newContent + '\n\n---\n\n[Content truncated due to length limit]';
}
}
// If single section is still too long, hard truncate
const truncated = sections[0]?.substring(0, maxLength - 50) ?? '';
return truncated + '\n\n[Content truncated due to length limit]';
}