feat: add category and scope to specs for enhanced filtering and organization

- Introduced SpecCategory and SpecScope types to categorize specs by workflow stage and scope (global/project).
- Updated Spec interface to include category and scope properties.
- Enhanced SpecCard component to display category and scope badges.
- Implemented category and scope filtering in SpecsSettingsPage.
- Updated localization files to support new category and scope labels.
- Modified spec loading commands to utilize category instead of keywords.
- Adjusted spec index builder to handle category and scope during spec parsing.
- Updated seed documents to include category information.
This commit is contained in:
catlog22
2026-02-26 23:43:55 +08:00
parent 052e25dddb
commit dfa8e0d9f5
47 changed files with 619 additions and 179 deletions

View File

@@ -5,6 +5,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
@@ -28,7 +29,7 @@ import {
Plug,
Download,
CheckCircle2,
ExternalLink,
Settings,
} from 'lucide-react';
import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings';
@@ -325,32 +326,34 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Button
onClick={handleInstallAllHooks}
disabled={allHooksInstalled || installingHookIds.length > 0}
>
{allHooksInstalled ? (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.allHooksInstalled', defaultMessage: 'All Hooks Installed' })}
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.installAllHooks', defaultMessage: 'Install All Hooks' })}
</>
)}
</Button>
<div className="text-sm text-muted-foreground">
{installedCount} / {RECOMMENDED_HOOKS.length}{' '}
{formatMessage({ id: 'specs.hooksInstalled', defaultMessage: 'installed' })}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
onClick={handleInstallAllHooks}
disabled={allHooksInstalled || installingHookIds.length > 0}
>
{allHooksInstalled ? (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.allHooksInstalled', defaultMessage: 'All Hooks Installed' })}
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.installAllHooks', defaultMessage: 'Install All Hooks' })}
</>
)}
</Button>
<div className="text-sm text-muted-foreground">
{installedCount} / {RECOMMENDED_HOOKS.length}{' '}
{formatMessage({ id: 'specs.hooksInstalled', defaultMessage: 'installed' })}
</div>
</div>
<Button variant="ghost" size="sm" asChild>
<a href="/hooks" target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" />
<Link to="/hooks">
<Settings className="h-4 w-4 mr-1" />
{formatMessage({ id: 'specs.manageHooks', defaultMessage: 'Manage Hooks' })}
</a>
</Link>
</Button>
</div>

View File

@@ -23,6 +23,10 @@ import {
Trash2,
FileText,
Tag,
Eye,
Globe,
Folder,
Layers,
} from 'lucide-react';
// ========== Types ==========
@@ -32,6 +36,11 @@ import {
*/
export type SpecDimension = 'specs' | 'personal';
/**
* Spec scope type
*/
export type SpecScope = 'global' | 'project';
/**
* Spec read mode type
*/
@@ -42,6 +51,11 @@ export type SpecReadMode = 'required' | 'optional';
*/
export type SpecPriority = 'critical' | 'high' | 'medium' | 'low';
/**
* Spec category type for workflow stage-based loading
*/
export type SpecCategory = 'general' | 'exploration' | 'planning' | 'execution';
/**
* Spec data structure
*/
@@ -54,6 +68,10 @@ export interface Spec {
file: string;
/** Spec dimension/category */
dimension: SpecDimension;
/** Scope: global (from ~/.ccw/) or project (from .ccw/) */
scope: SpecScope;
/** Workflow stage category for system-level loading */
category?: SpecCategory;
/** Read mode: required (always inject) or optional (keyword match) */
readMode: SpecReadMode;
/** Priority level */
@@ -72,6 +90,8 @@ export interface Spec {
export interface SpecCardProps {
/** Spec data */
spec: Spec;
/** Called when view content action is triggered */
onView?: (spec: Spec) => void;
/** Called when edit action is triggered */
onEdit?: (spec: Spec) => void;
/** Called when delete action is triggered */
@@ -108,6 +128,17 @@ const priorityConfig: Record<
low: { variant: 'secondary', labelKey: 'specs.priority.low' },
};
// Category badge configuration for workflow stage
const categoryConfig: Record<
SpecCategory,
{ variant: 'default' | 'secondary' | 'outline'; labelKey: string }
> = {
general: { variant: 'secondary', labelKey: 'specs.category.general' },
exploration: { variant: 'outline', labelKey: 'specs.category.exploration' },
planning: { variant: 'outline', labelKey: 'specs.category.planning' },
execution: { variant: 'outline', labelKey: 'specs.category.execution' },
};
// ========== Component ==========
/**
@@ -115,6 +146,7 @@ const priorityConfig: Record<
*/
export function SpecCard({
spec,
onView,
onEdit,
onDelete,
onToggle,
@@ -181,6 +213,10 @@ export function SpecCard({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onView?.(spec); }}>
<Eye className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.view', defaultMessage: 'View Content' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.edit' })}
@@ -201,6 +237,29 @@ export function SpecCard({
{/* Badges */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{/* Category badge - workflow stage */}
{spec.category && (
<Badge variant={categoryConfig[spec.category].variant} className="text-xs gap-1">
<Layers className="h-3 w-3" />
{formatMessage({ id: categoryConfig[spec.category].labelKey, defaultMessage: spec.category })}
</Badge>
)}
{/* Scope badge - only show for personal specs */}
{spec.dimension === 'personal' && (
<Badge variant="outline" className="text-xs gap-1">
{spec.scope === 'global' ? (
<>
<Globe className="h-3 w-3" />
{formatMessage({ id: 'specs.scope.global', defaultMessage: 'Global' })}
</>
) : (
<>
<Folder className="h-3 w-3" />
{formatMessage({ id: 'specs.scope.project', defaultMessage: 'Project' })}
</>
)}
</Badge>
)}
<Badge variant={readMode.variant} className="text-xs">
{formatMessage({ id: readMode.labelKey })}
</Badge>

View File

@@ -11,8 +11,10 @@ export {
export type {
Spec,
SpecDimension,
SpecScope,
SpecReadMode,
SpecPriority,
SpecCategory,
SpecCardProps,
} from './SpecCard';

View File

@@ -350,6 +350,7 @@ export const specsSettingsKeys = {
all: ['specsSettings'] as const,
systemSettings: () => [...specsSettingsKeys.all, 'systemSettings'] as const,
specStats: (projectPath?: string) => [...specsSettingsKeys.all, 'specStats', projectPath] as const,
specsList: (projectPath?: string) => [...specsSettingsKeys.all, 'specsList', projectPath] as const,
};
// ========================================
@@ -492,7 +493,7 @@ export function useSpecsList(options: UseSpecsListOptions = {}): UseSpecsListRet
const { projectPath, enabled = true, staleTime = STALE_TIME } = options;
const query = useQuery({
queryKey: specsSettingsKeys.specStats(projectPath), // Reuse for specs list
queryKey: specsSettingsKeys.specsList(projectPath),
queryFn: () => getSpecsList(projectPath),
staleTime,
enabled,
@@ -528,6 +529,7 @@ export function useRebuildSpecIndex(options: UseRebuildSpecIndexOptions = {}) {
onSuccess: () => {
// Invalidate specs list and stats queries to refresh data
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.specStats(projectPath) });
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.specsList(projectPath) });
},
});
@@ -560,8 +562,9 @@ export function useUpdateSpecFrontmatter(options: UseUpdateSpecFrontmatterOption
mutationFn: ({ file, readMode }: { file: string; readMode: string }) =>
updateSpecFrontmatter(file, readMode, projectPath),
onSuccess: () => {
// Invalidate specs list to refresh data
// Invalidate specs list and stats to refresh data
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.specStats(projectPath) });
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.specsList(projectPath) });
},
});

View File

@@ -7268,9 +7268,11 @@ export interface SpecEntry {
file: string;
title: string;
dimension: string;
category?: 'general' | 'exploration' | 'planning' | 'execution';
readMode: 'required' | 'optional' | 'keywords';
priority: 'critical' | 'high' | 'medium' | 'low';
keywords: string[];
scope: 'global' | 'project';
}
/**

View File

@@ -10,36 +10,67 @@
"rebuildIndex": "Rebuild Index",
"loading": "Loading...",
"noSpecs": "No specs found. Create specs in .ccw/ directory.",
"required": "required",
"dimension": {
"specs": "Project Specs",
"personal": "Personal"
},
"scope": {
"all": "All",
"global": "Global",
"project": "Project"
},
"filterByScope": "Filter by scope:",
"category": {
"general": "General",
"exploration": "Exploration",
"planning": "Planning",
"execution": "Execution"
},
"recommendedHooks": "Recommended Hooks",
"recommendedHooksDesc": "One-click install system-preset spec injection hooks",
"installAll": "Install All Recommended Hooks",
"installAllHooks": "Install All Hooks",
"allHooksInstalled": "All Hooks Installed",
"hooksInstalled": "installed",
"manageHooks": "Manage Hooks",
"hookEvent": "Event",
"hookScope": "Scope",
"install": "Install",
"installed": "Installed",
"installing": "Installing...",
"installedHooks": "Installed Hooks",
"installedHooksDesc": "Manage your installed hooks configuration",
"searchHooks": "Search hooks...",
"noHooks": "No hooks installed. Install recommended hooks above.",
"spec": {
"edit": "Edit",
"toggle": "Toggle",
"delete": "Delete",
"required": "Required",
"optional": "Optional",
"priority": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
}
"edit": "Edit Spec",
"toggle": "Toggle Status",
"delete": "Delete Spec",
"deleteConfirm": "Are you sure you want to delete this spec?",
"title": "Spec Title",
"keywords": "Keywords",
"keywordsPlaceholder": "Enter keywords, separated by commas",
"readMode": "Read Mode",
"priority": "Priority",
"file": "File Path"
},
"hook": {
"install": "Install",
"edit": "Edit",
"toggle": "Toggle",
"delete": "Delete",
"uninstall": "Uninstall",
"edit": "Edit Hook",
"toggle": "Toggle Status",
"delete": "Delete Hook",
"enabled": "Enabled",
"disabled": "Disabled",
"installed": "Installed",
"notInstalled": "Not Installed",
"scope": {
"global": "Global",
"project": "Project"
@@ -48,21 +79,142 @@
"SessionStart": "Session Start",
"UserPromptSubmit": "Prompt Submit",
"SessionEnd": "Session End"
},
"name": "Hook Name",
"eventLabel": "Trigger Event",
"command": "Command",
"scopeLabel": "Scope",
"timeout": "Timeout (ms)",
"failMode": "Fail Mode",
"failModeContinue": "Continue",
"failModeBlock": "Block",
"failModeWarn": "Warn"
},
"actions": {
"edit": "Edit",
"delete": "Delete",
"reset": "Reset",
"save": "Save",
"saving": "Saving...",
"view": "View Content"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"readMode": {
"required": "Required",
"optional": "Optional"
},
"priority": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"hooks": {
"dialog": {
"createTitle": "Create Hook",
"editTitle": "Edit Hook",
"description": "Configure the hook trigger event, command, and other settings."
},
"fields": {
"name": "Hook Name",
"event": "Trigger Event",
"scope": "Scope",
"command": "Command",
"description": "Description",
"timeout": "Timeout",
"timeoutUnit": "ms",
"failMode": "Failure Mode"
},
"placeholders": {
"name": "Enter hook name",
"event": "Select event",
"command": "Enter command to execute",
"description": "Enter description (optional)"
},
"events": {
"sessionStart": "Session Start",
"userPromptSubmit": "Prompt Submit",
"sessionEnd": "Session End"
},
"scope": {
"global": "Global",
"project": "Project"
},
"failModes": {
"continue": "Continue",
"warn": "Show Warning",
"block": "Block Operation"
},
"validation": {
"nameRequired": "Name is required",
"commandRequired": "Command is required",
"timeoutMin": "Minimum timeout is 1000ms",
"timeoutMax": "Maximum timeout is 300000ms"
}
},
"hints": {
"hookEvents": "Select when this hook should be triggered",
"hookScope": "Global hooks apply to all projects, project hooks only to current project",
"hookCommand": "Command to execute, can use environment variables",
"hookTimeout": "Timeout for command execution",
"hookFailMode": "How to handle command execution failure"
},
"common": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"reset": "Reset",
"confirm": "Confirm",
"close": "Close"
},
"content": {
"edit": "Edit",
"view": "View",
"metadata": "Metadata",
"markdownContent": "Markdown Content",
"noContent": "No content available",
"editHint": "Edit the full markdown content including frontmatter. Changes to frontmatter will be reflected in the spec metadata.",
"placeholder": "# Spec Title\n\nContent here..."
},
"injection": {
"title": "Injection Control",
"description": "Monitor and manage spec injection length",
"statusTitle": "Current Injection Status",
"settingsTitle": "Injection Control Settings",
"settingsDescription": "Configure how spec content is injected into AI context.",
"currentLength": "Current Length",
"maxLength": "Max Length",
"maxLength": "Max Injection Length (characters)",
"maxLengthHelp": "Recommended: 4000-10000. Too large may consume too much context; too small may truncate important specs.",
"warnThreshold": "Warn Threshold",
"warnThresholdLabel": "Warning Threshold (characters)",
"warnThresholdHelp": "A warning will be displayed when injection length exceeds this value.",
"percentage": "Usage",
"truncateOnExceed": "Truncate on Exceed",
"truncateDescription": "Automatically truncate when injection exceeds max length",
"truncateHelp": "Automatically truncate content when it exceeds the maximum length.",
"overLimit": "Over Limit",
"warning": "Warning",
"normal": "Normal"
"overLimitDescription": "Current injection content exceeds maximum length of {max} characters. Excess content will be truncated.",
"warning": "Approaching Limit",
"normal": "Normal",
"characters": "characters",
"statsInfo": "Statistics",
"requiredLength": "Required specs length:",
"matchedLength": "Keyword-matched length:",
"remaining": "Remaining space:",
"loadError": "Failed to load stats",
"saveSuccess": "Settings saved successfully",
"saveError": "Failed to save settings"
},
"settings": {
@@ -70,6 +222,7 @@
"description": "Configure personal spec defaults and system settings",
"personalSpecDefaults": "Personal Spec Defaults",
"defaultReadMode": "Default Read Mode",
"defaultReadModeHelp": "Default read mode for newly created personal specs",
"autoEnable": "Auto Enable",
"autoEnableDescription": "Automatically enable newly created personal specs"
},
@@ -77,17 +230,25 @@
"dialog": {
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"editSpec": "Edit Spec",
"editHook": "Edit Hook",
"confirmDelete": "Confirm Delete",
"specTitle": "Spec Title",
"keywords": "Keywords",
"readMode": "Read Mode",
"priority": "Priority",
"hookName": "Hook Name",
"hookEvent": "Event",
"hookEvent": "Trigger Event",
"hookCommand": "Command",
"hookScope": "Scope",
"hookTimeout": "Timeout (ms)",
"hookFailMode": "Fail Mode"
},
"form": {
"readMode": "Read Mode",
"priority": "Priority",
"keywords": "Keywords"
}
}

View File

@@ -10,16 +10,47 @@
"rebuildIndex": "重建索引",
"loading": "加载中...",
"noSpecs": "未找到规范。请在 .ccw/ 目录中创建规范文件。",
"required": "必读",
"dimension": {
"specs": "项目规范",
"personal": "个人规范"
},
"scope": {
"all": "全部",
"global": "全局",
"project": "项目"
},
"filterByScope": "按范围筛选:",
"category": {
"general": "通用",
"exploration": "探索",
"planning": "规划",
"execution": "执行"
},
"recommendedHooks": "推荐钩子",
"recommendedHooksDesc": "一键安装系统预设的规范注入钩子",
"installAll": "安装所有推荐钩子",
"installAllHooks": "安装所有钩子",
"allHooksInstalled": "已安装所有钩子",
"hooksInstalled": "已安装",
"manageHooks": "管理钩子",
"hookEvent": "事件",
"hookScope": "范围",
"install": "安装",
"installed": "已安装",
"installing": "安装中...",
"installedHooks": "已安装钩子",
"installedHooksDesc": "管理已安装的钩子配置",
"searchHooks": "搜索钩子...",
"noHooks": "未安装钩子。请安装上方的推荐钩子。",
"actions": {
"view": "查看内容",
"edit": "编辑",
"delete": "删除",
"reset": "重置",
@@ -45,6 +76,7 @@
},
"spec": {
"view": "查看内容",
"edit": "编辑规范",
"toggle": "切换状态",
"delete": "删除规范",
@@ -57,6 +89,23 @@
"file": "文件路径"
},
"content": {
"edit": "编辑",
"view": "查看",
"metadata": "元数据",
"markdownContent": "Markdown 内容",
"noContent": "无内容",
"editHint": "编辑完整的 Markdown 内容(包括 frontmatter。frontmatter 的更改将反映到规范元数据中。",
"placeholder": "# 规范标题\n\n内容..."
},
"common": {
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"close": "关闭"
},
"hook": {
"install": "安装",
"uninstall": "卸载",
@@ -88,6 +137,9 @@
},
"hooks": {
"installSuccess": "钩子安装成功",
"installError": "钩子安装失败",
"installAllSuccess": "所有钩子安装成功",
"dialog": {
"createTitle": "创建钩子",
"editTitle": "编辑钩子",
@@ -122,6 +174,12 @@
"continue": "继续执行",
"warn": "显示警告",
"block": "阻止操作"
},
"validation": {
"nameRequired": "名称为必填项",
"commandRequired": "命令为必填项",
"timeoutMin": "最小超时时间为 1000ms",
"timeoutMax": "最大超时时间为 300000ms"
}
},
@@ -133,18 +191,8 @@
"hookFailMode": "命令执行失败时的处理方式"
},
"common": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"reset": "重置",
"confirm": "确认"
},
"injection": {
"title": "注入控制",
"description": "监控和管理规范注入长度",
"statusTitle": "当前注入状态",
"settingsTitle": "注入控制设置",
"settingsDescription": "配置如何将规范内容注入到 AI 上下文中。",
@@ -198,5 +246,11 @@
"hookScope": "作用域",
"hookTimeout": "超时时间(ms)",
"hookFailMode": "失败模式"
},
"form": {
"readMode": "读取模式",
"priority": "优先级",
"keywords": "关键词"
}
}

View File

@@ -3,6 +3,7 @@
*
* Main page for managing spec settings, injection control, and global settings.
* Uses 4 tabs: Project Specs | Personal Specs | Injection | Settings
* Supports category filtering (workflow stage) and scope filtering (personal only)
*/
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
@@ -10,8 +11,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ScrollText, User, Gauge, Settings, RefreshCw, Search } from 'lucide-react';
import { SpecCard, SpecDialog, SpecContentDialog, type Spec, type SpecFormData } from '@/components/specs';
import { ScrollText, User, Gauge, Settings, RefreshCw, Search, Globe, Folder, Filter, Layers } from 'lucide-react';
import { SpecCard, SpecDialog, SpecContentDialog, type Spec, type SpecFormData, type SpecCategory } from '@/components/specs';
import { InjectionControlTab } from '@/components/specs/InjectionControlTab';
import { GlobalSettingsTab } from '@/components/specs/GlobalSettingsTab';
import { useSpecStats, useSpecsList, useRebuildSpecIndex } from '@/hooks/useSystemSettings';
@@ -19,6 +20,11 @@ import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import type { SpecEntry } from '@/lib/api';
type SettingsTab = 'project-specs' | 'personal-specs' | 'injection' | 'settings';
type PersonalScopeFilter = 'all' | 'global' | 'project';
type CategoryFilter = 'all' | SpecCategory;
// All available categories
const SPEC_CATEGORIES: SpecCategory[] = ['general', 'exploration', 'planning', 'execution'];
// Convert SpecEntry to Spec for display
function specEntryToSpec(entry: SpecEntry, dimension: string): Spec {
@@ -26,6 +32,8 @@ function specEntryToSpec(entry: SpecEntry, dimension: string): Spec {
id: entry.file,
title: entry.title,
dimension: dimension as Spec['dimension'],
scope: entry.scope || 'project', // Default to project if not specified
category: entry.category || 'general', // Default to general if not specified
keywords: entry.keywords,
readMode: entry.readMode as Spec['readMode'],
priority: entry.priority as Spec['priority'],
@@ -39,8 +47,12 @@ export function SpecsSettingsPage() {
const projectPath = useWorkflowStore(selectProjectPath);
const [activeTab, setActiveTab] = useState<SettingsTab>('project-specs');
const [searchQuery, setSearchQuery] = useState('');
const [personalScopeFilter, setPersonalScopeFilter] = useState<PersonalScopeFilter>('all');
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [contentDialogOpen, setContentDialogOpen] = useState(false);
const [editingSpec, setEditingSpec] = useState<Spec | null>(null);
const [viewingSpec, setViewingSpec] = useState<Spec | null>(null);
// Fetch real data
const { data: specsListData, isLoading: specsLoading, refetch: refetchSpecs } = useSpecsList({ projectPath });
@@ -48,26 +60,50 @@ export function SpecsSettingsPage() {
const rebuildMutation = useRebuildSpecIndex();
// Convert specs data to display format
const { projectSpecs, personalSpecs } = useMemo(() => {
const { projectSpecs, personalSpecs, globalPersonalSpecs, projectPersonalSpecs, categoryCounts } = useMemo(() => {
if (!specsListData?.specs) {
return { projectSpecs: [], personalSpecs: [] };
return {
projectSpecs: [],
personalSpecs: [],
globalPersonalSpecs: [],
projectPersonalSpecs: [],
categoryCounts: { general: 0, exploration: 0, planning: 0, execution: 0 }
};
}
const specs: Spec[] = [];
const personal: Spec[] = [];
const globalPersonal: Spec[] = [];
const projectPersonal: Spec[] = [];
const counts: Record<SpecCategory, number> = { general: 0, exploration: 0, planning: 0, execution: 0 };
for (const [dimension, entries] of Object.entries(specsListData.specs)) {
for (const entry of entries) {
const spec = specEntryToSpec(entry, dimension);
// Count by category
if (spec.category) {
counts[spec.category]++;
}
if (dimension === 'personal') {
personal.push(spec);
if (spec.scope === 'global') {
globalPersonal.push(spec);
} else {
projectPersonal.push(spec);
}
} else {
specs.push(spec);
}
}
}
return { projectSpecs: specs, personalSpecs: personal };
return {
projectSpecs: specs,
personalSpecs: personal,
globalPersonalSpecs: globalPersonal,
projectPersonalSpecs: projectPersonal,
categoryCounts: counts
};
}, [specsListData]);
const isLoading = specsLoading;
@@ -113,16 +149,34 @@ export function SpecsSettingsPage() {
};
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))
);
let result = specs;
// Filter by category
if (categoryFilter !== 'all') {
result = result.filter(spec => spec.category === categoryFilter);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(spec =>
spec.title.toLowerCase().includes(query) ||
spec.keywords.some(k => k.toLowerCase().includes(query))
);
}
return result;
};
const renderSpecsTab = (dimension: 'project' | 'personal') => {
const specs = dimension === 'project' ? projectSpecs : personalSpecs;
let specs = dimension === 'project' ? projectSpecs : personalSpecs;
// Apply scope filter for personal specs
if (dimension === 'personal') {
if (personalScopeFilter === 'global') {
specs = globalPersonalSpecs;
} else if (personalScopeFilter === 'project') {
specs = projectPersonalSpecs;
}
}
const filteredSpecs = filterSpecs(specs);
return (
@@ -144,13 +198,77 @@ export function SpecsSettingsPage() {
</Button>
</div>
{/* Scope filter for personal specs */}
{dimension === 'personal' && (
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.filterByScope', defaultMessage: 'Filter by scope:' })}
</span>
<div className="flex gap-2">
<Button
variant={personalScopeFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setPersonalScopeFilter('all')}
>
{formatMessage({ id: 'specs.scope.all', defaultMessage: 'All' })} ({personalSpecs.length})
</Button>
<Button
variant={personalScopeFilter === 'global' ? 'default' : 'outline'}
size="sm"
onClick={() => setPersonalScopeFilter('global')}
>
<Globe className="h-3 w-3 mr-1" />
{formatMessage({ id: 'specs.scope.global', defaultMessage: 'Global' })} ({globalPersonalSpecs.length})
</Button>
<Button
variant={personalScopeFilter === 'project' ? 'default' : 'outline'}
size="sm"
onClick={() => setPersonalScopeFilter('project')}
>
<Folder className="h-3 w-3 mr-1" />
{formatMessage({ id: 'specs.scope.project', defaultMessage: 'Project' })} ({projectPersonalSpecs.length})
</Button>
</div>
</div>
)}
{/* Category filter for workflow stage */}
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.filterByCategory', defaultMessage: 'Workflow stage:' })}
</span>
<div className="flex gap-2 flex-wrap">
<Button
variant={categoryFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setCategoryFilter('all')}
>
{formatMessage({ id: 'specs.category.all', defaultMessage: 'All' })}
</Button>
{SPEC_CATEGORIES.map(cat => (
<Button
key={cat}
variant={categoryFilter === cat ? 'default' : 'outline'}
size="sm"
onClick={() => setCategoryFilter(cat)}
>
{formatMessage({ id: `specs.category.${cat}`, defaultMessage: cat })} ({categoryCounts[cat]})
</Button>
))}
</div>
</div>
{/* Stats Summary */}
{statsData?.dimensions && (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 md: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-sm text-muted-foreground">
{formatMessage({ id: `specs.dimension.${dim}`, defaultMessage: 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} {formatMessage({ id: 'specs.required', defaultMessage: 'required' })}
@@ -167,7 +285,7 @@ export function SpecsSettingsPage() {
<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.' })
: formatMessage({ id: 'specs.noSpecs', defaultMessage: 'No specs found. Create specs in .ccw/ directory.' })
}
</CardContent>
</Card>

View File

@@ -376,13 +376,13 @@ ${chalk.bold('EXAMPLES')}
ccw spec init
${chalk.gray('# Load exploration-phase specs:')}
ccw spec load --keywords exploration
ccw spec load --category exploration
${chalk.gray('# Load planning-phase specs with auth topic:')}
ccw spec load --keywords "planning auth"
ccw spec load --category "planning auth"
${chalk.gray('# Load execution-phase specs:')}
ccw spec load --keywords execution
ccw spec load --category execution
${chalk.gray('# Load specs for a topic (CLI mode):')}
ccw spec load --dimension specs --keywords "auth jwt security"

View File

@@ -10,6 +10,7 @@
* ---
* title: "Document Title"
* dimension: "specs"
* category: "general" # general | exploration | planning | execution
* keywords: ["auth", "security"]
* readMode: "required" # required | optional
* priority: "high" # critical | high | medium | low
@@ -19,21 +20,25 @@
import matter from 'gray-matter';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join, basename, extname, relative } from 'path';
import { homedir } from 'os';
// ============================================================================
// Types
// ============================================================================
/**
* Spec categories for workflow stage-based loading (used as keywords).
* Spec categories for workflow stage-based loading.
* - general: Applies to all stages (e.g. coding conventions)
* - exploration: Code exploration, analysis, debugging context
* - planning: Task planning, roadmap, requirements context
* - execution: Implementation, testing, deployment context
*
* Usage: Add these as keywords in spec frontmatter, e.g.:
* keywords: [exploration, auth, security]
* Usage: Set category field in spec frontmatter:
* category: exploration
*
* System-level loading by stage: ccw spec load --category exploration
*/
export const SPEC_CATEGORIES = ['exploration', 'planning', 'execution'] as const;
export const SPEC_CATEGORIES = ['general', 'exploration', 'planning', 'execution'] as const;
export type SpecCategory = typeof SPEC_CATEGORIES[number];
@@ -43,6 +48,7 @@ export type SpecCategory = typeof SPEC_CATEGORIES[number];
export interface SpecFrontmatter {
title: string;
dimension: string;
category?: SpecCategory;
keywords: string[];
readMode: 'required' | 'optional';
priority: 'critical' | 'high' | 'medium' | 'low';
@@ -58,12 +64,16 @@ export interface SpecIndexEntry {
file: string;
/** Dimension this spec belongs to */
dimension: string;
/** Keywords for matching against user prompts (may include category markers) */
/** Workflow stage category for system-level loading */
category: SpecCategory;
/** Keywords for matching against user prompts */
keywords: string[];
/** Whether this spec is required or optional */
readMode: 'required' | 'optional';
/** Priority level for ordering */
priority: 'critical' | 'high' | 'medium' | 'low';
/** Scope: global (from ~/.ccw/) or project (from .ccw/) */
scope: 'global' | 'project';
}
/**
@@ -101,6 +111,11 @@ const VALID_READ_MODES = ['required', 'optional'] as const;
*/
const VALID_PRIORITIES = ['critical', 'high', 'medium', 'low'] as const;
/**
* Valid category values.
*/
const VALID_CATEGORIES = SPEC_CATEGORIES;
/**
* Directory name for spec index cache files (inside .ccw/).
*/
@@ -149,45 +164,42 @@ export async function buildDimensionIndex(
projectPath: string,
dimension: string
): Promise<DimensionIndex> {
const dimensionDir = getDimensionDir(projectPath, dimension);
const entries: SpecIndexEntry[] = [];
// If directory doesn't exist, return empty index
if (!existsSync(dimensionDir)) {
return {
dimension,
entries: [],
built_at: new Date().toISOString(),
};
}
// Helper function to scan a directory and add entries
const scanDirectory = (dir: string, scope: 'global' | 'project') => {
if (!existsSync(dir)) return;
// Scan for .md files
let files: string[];
try {
files = readdirSync(dimensionDir).filter(
f => extname(f).toLowerCase() === '.md'
);
} catch {
// Directory read error - return empty index
return {
dimension,
entries: [],
built_at: new Date().toISOString(),
};
}
for (const file of files) {
const filePath = join(dimensionDir, file);
const entry = parseSpecFile(filePath, dimension, projectPath);
if (entry) {
entries.push(entry);
} else {
process.stderr.write(
`[spec-index-builder] Skipping malformed spec file: ${file}\n`
);
let files: string[];
try {
files = readdirSync(dir).filter(f => extname(f).toLowerCase() === '.md');
} catch {
return;
}
for (const file of files) {
const filePath = join(dir, file);
const entry = parseSpecFile(filePath, dimension, projectPath, scope);
if (entry) {
entries.push(entry);
} else {
process.stderr.write(
`[spec-index-builder] Skipping malformed spec file: ${file}\n`
);
}
}
};
// For personal dimension, also scan global ~/.ccw/personal/
if (dimension === 'personal') {
const globalPersonalDir = join(homedir(), '.ccw', 'personal');
scanDirectory(globalPersonalDir, 'global');
}
// Scan project dimension directory
const dimensionDir = getDimensionDir(projectPath, dimension);
scanDirectory(dimensionDir, 'project');
return {
dimension,
entries,
@@ -315,7 +327,8 @@ export async function getDimensionIndex(
function parseSpecFile(
filePath: string,
dimension: string,
projectPath: string
projectPath: string,
scope: 'global' | 'project' = 'project'
): SpecIndexEntry | null {
let content: string;
try {
@@ -340,10 +353,10 @@ function parseSpecFile(
if (!title) {
// Title is required - use filename as fallback
const fallbackTitle = basename(filePath, extname(filePath));
return buildEntry(fallbackTitle, filePath, dimension, projectPath, data);
return buildEntry(fallbackTitle, filePath, dimension, projectPath, data, scope);
}
return buildEntry(title, filePath, dimension, projectPath, data);
return buildEntry(title, filePath, dimension, projectPath, data, scope);
}
/**
@@ -354,12 +367,17 @@ function buildEntry(
filePath: string,
dimension: string,
projectPath: string,
data: Record<string, unknown>
data: Record<string, unknown>,
scope: 'global' | 'project' = 'project'
): SpecIndexEntry {
// Compute relative file path from project root using path.relative
// Normalize to forward slashes for cross-platform consistency
const relativePath = relative(projectPath, filePath).replace(/\\/g, '/');
// Extract category with validation (defaults to 'general')
const rawCategory = extractString(data, 'category');
const category = isValidCategory(rawCategory) ? rawCategory : 'general';
// Extract keywords - accept string[] or single string
const keywords = extractStringArray(data, 'keywords');
@@ -375,9 +393,11 @@ function buildEntry(
title,
file: relativePath,
dimension,
category,
keywords,
readMode,
priority,
scope,
};
}
@@ -435,3 +455,10 @@ function isValidReadMode(value: string | null): value is 'required' | 'optional'
function isValidPriority(value: string | null): value is 'critical' | 'high' | 'medium' | 'low' {
return value !== null && (VALID_PRIORITIES as readonly string[]).includes(value);
}
/**
* Type guard for valid category values.
*/
function isValidCategory(value: string | null): value is SpecCategory {
return value !== null && (VALID_CATEGORIES as readonly string[]).includes(value);
}

View File

@@ -18,6 +18,7 @@ import { join } from 'path';
export interface SpecFrontmatter {
title: string;
dimension: string;
category?: 'general' | 'exploration' | 'planning' | 'execution';
keywords: string[];
readMode: 'required' | 'optional';
priority: 'high' | 'medium' | 'low';
@@ -55,7 +56,8 @@ export const SEED_DOCS: Map<string, SeedDoc[]> = new Map([
frontmatter: {
title: 'Coding Conventions',
dimension: 'specs',
keywords: ['typescript', 'naming', 'style', 'convention', 'exploration', 'planning', 'execution'],
category: 'general',
keywords: ['typescript', 'naming', 'style', 'convention'],
readMode: 'required',
priority: 'high',
},
@@ -91,7 +93,8 @@ export const SEED_DOCS: Map<string, SeedDoc[]> = new Map([
frontmatter: {
title: 'Architecture Constraints',
dimension: 'specs',
keywords: ['architecture', 'module', 'layer', 'pattern', 'exploration', 'planning'],
category: 'planning',
keywords: ['architecture', 'module', 'layer', 'pattern'],
readMode: 'required',
priority: 'high',
},
@@ -126,6 +129,7 @@ export const SEED_DOCS: Map<string, SeedDoc[]> = new Map([
frontmatter: {
title: 'Personal Coding Style',
dimension: 'personal',
category: 'general',
keywords: ['style', 'preference'],
readMode: 'optional',
priority: 'medium',
@@ -153,6 +157,7 @@ export const SEED_DOCS: Map<string, SeedDoc[]> = new Map([
frontmatter: {
title: 'Tool Preferences',
dimension: 'personal',
category: 'general',
keywords: ['tool', 'cli', 'editor'],
readMode: 'optional',
priority: 'low',
@@ -186,16 +191,22 @@ export const SEED_DOCS: Map<string, SeedDoc[]> = new Map([
*/
export function formatFrontmatter(fm: SpecFrontmatter): string {
const keywordsYaml = fm.keywords.map((k) => ` - ${k}`).join('\n');
return [
const lines = [
'---',
`title: "${fm.title}"`,
`dimension: ${fm.dimension}`,
];
if (fm.category) {
lines.push(`category: ${fm.category}`);
}
lines.push(
`keywords:`,
keywordsYaml,
`readMode: ${fm.readMode}`,
`priority: ${fm.priority}`,
'---',
].join('\n');
'---'
);
return lines.join('\n');
}
// ---------------------------------------------------------------------------