feat(workflow): add unified workflow spec command system

- Add /workflow:init-specs command for interactive spec creation with scope selection (global/project)
- Update /workflow:init to chain solidify and add --skip-specs flag
- Add category field support to generated specs frontmatter
- Add GET /api/project-tech/stats endpoint for development progress stats
- Add devProgressInjection settings to system configuration
- Add development progress injection control card to GlobalSettingsTab
- Add i18n keys for new settings in en/zh locales
This commit is contained in:
catlog22
2026-02-27 12:25:26 +08:00
parent 4d755ff9b4
commit 99a3561f71
10 changed files with 877 additions and 33 deletions

View File

@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { Settings, RefreshCw } from 'lucide-react';
import { Settings, RefreshCw, History } from 'lucide-react';
import {
Card,
CardHeader,
@@ -18,6 +18,8 @@ import {
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Switch } from '@/components/ui/Switch';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectTrigger,
@@ -34,6 +36,12 @@ interface PersonalSpecDefaults {
autoEnable: boolean;
}
interface DevProgressInjection {
enabled: boolean;
maxEntriesPerCategory: number;
categories: ('feature' | 'enhancement' | 'bugfix' | 'refactor' | 'docs')[];
}
interface SystemSettings {
injectionControl: {
maxLength: number;
@@ -41,6 +49,7 @@ interface SystemSettings {
truncateOnExceed: boolean;
};
personalSpecDefaults: PersonalSpecDefaults;
devProgressInjection: DevProgressInjection;
}
interface SpecDimensionStats {
@@ -61,6 +70,19 @@ interface SpecStats {
};
}
interface ProjectTechStats {
total_features: number;
total_sessions: number;
last_updated: string | null;
categories: {
feature: number;
enhancement: number;
bugfix: number;
refactor: number;
docs: number;
};
}
// ========== API Functions ==========
const API_BASE = '/api';
@@ -103,12 +125,23 @@ async function fetchSpecStats(): Promise<SpecStats> {
return response.json();
}
async function fetchProjectTechStats(): Promise<ProjectTechStats> {
const response = await fetch(`${API_BASE}/project-tech/stats`, {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to fetch project-tech 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,
projectTech: () => [...settingsKeys.all, 'project-tech'] as const,
};
// ========== Component ==========
@@ -123,6 +156,13 @@ export function GlobalSettingsTab() {
autoEnable: true,
});
// Local state for dev progress injection
const [localDevProgress, setLocalDevProgress] = useState<DevProgressInjection>({
enabled: true,
maxEntriesPerCategory: 10,
categories: ['feature', 'enhancement', 'bugfix', 'refactor', 'docs'],
});
// Fetch system settings
const {
data: settings,
@@ -146,6 +186,16 @@ export function GlobalSettingsTab() {
staleTime: 30000, // 30 seconds
});
// Fetch project-tech stats
const {
data: projectTechStats,
isLoading: isLoadingProjectTech,
} = useQuery({
queryKey: settingsKeys.projectTech(),
queryFn: fetchProjectTechStats,
staleTime: 60000, // 1 minute
});
// Update settings mutation
const updateMutation = useMutation({
mutationFn: updateSystemSettings,
@@ -166,6 +216,9 @@ export function GlobalSettingsTab() {
if (settings?.personalSpecDefaults) {
setLocalDefaults(settings.personalSpecDefaults);
}
if (settings?.devProgressInjection) {
setLocalDevProgress(settings.devProgressInjection);
}
}, [settings]);
// Handlers
@@ -181,6 +234,31 @@ export function GlobalSettingsTab() {
updateMutation.mutate({ personalSpecDefaults: newDefaults });
};
// Dev progress injection handlers
const handleDevProgressToggle = (checked: boolean) => {
const newDevProgress = { ...localDevProgress, enabled: checked };
setLocalDevProgress(newDevProgress);
updateMutation.mutate({ devProgressInjection: newDevProgress });
};
const handleMaxEntriesChange = (value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue >= 1 && numValue <= 50) {
const newDevProgress = { ...localDevProgress, maxEntriesPerCategory: numValue };
setLocalDevProgress(newDevProgress);
updateMutation.mutate({ devProgressInjection: newDevProgress });
}
};
const handleCategoryToggle = (category: 'feature' | 'enhancement' | 'bugfix' | 'refactor' | 'docs') => {
const newCategories = localDevProgress.categories.includes(category)
? localDevProgress.categories.filter(c => c !== category)
: [...localDevProgress.categories, category];
const newDevProgress = { ...localDevProgress, categories: newCategories as DevProgressInjection['categories'] };
setLocalDevProgress(newDevProgress);
updateMutation.mutate({ devProgressInjection: newDevProgress });
};
// Calculate totals - Only include specs and personal dimensions
const dimensions = stats?.dimensions || {};
const dimensionEntries = Object.entries(dimensions)
@@ -345,6 +423,107 @@ export function GlobalSettingsTab() {
)}
</CardContent>
</Card>
{/* Development Progress Injection Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<History className="h-5 w-5 text-muted-foreground" />
<CardTitle>
{formatMessage({ id: 'specs.settings.devProgressInjection', defaultMessage: 'Development Progress Injection' })}
</CardTitle>
</div>
<CardDescription>
{formatMessage({ id: 'specs.settings.devProgressInjectionDesc', defaultMessage: 'Control how development progress from project-tech.json is injected into AI context' })}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>
{formatMessage({ id: 'specs.settings.enableDevProgress', defaultMessage: 'Enable Injection' })}
</Label>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.settings.enableDevProgressDesc', defaultMessage: 'Include development history in AI context' })}
</p>
</div>
<Switch
checked={localDevProgress.enabled}
onCheckedChange={handleDevProgressToggle}
disabled={updateMutation.isPending}
/>
</div>
{/* Max Entries */}
<div className="space-y-2">
<Label>
{formatMessage({ id: 'specs.settings.maxEntries', defaultMessage: 'Max Entries per Category' })}
</Label>
<Input
type="number"
min={1}
max={50}
value={localDevProgress.maxEntriesPerCategory}
onChange={(e) => handleMaxEntriesChange(e.target.value)}
disabled={updateMutation.isPending || !localDevProgress.enabled}
className="w-24"
/>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.settings.maxEntriesDesc', defaultMessage: 'Maximum number of entries to include per category (1-50)' })}
</p>
</div>
{/* Category Toggles */}
<div className="space-y-2">
<Label>
{formatMessage({ id: 'specs.settings.includeCategories', defaultMessage: 'Include Categories' })}
</Label>
<div className="flex flex-wrap gap-2">
{(['feature', 'enhancement', 'bugfix', 'refactor', 'docs'] as const).map(cat => (
<Badge
key={cat}
variant={localDevProgress.categories.includes(cat) ? 'default' : 'outline'}
className={cn(
'cursor-pointer transition-colors',
!localDevProgress.enabled && 'opacity-50 cursor-not-allowed'
)}
onClick={() => localDevProgress.enabled && handleCategoryToggle(cat)}
>
{cat} ({projectTechStats?.categories[cat] || 0})
</Badge>
))}
</div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.settings.categoriesDesc', defaultMessage: 'Click to toggle category inclusion' })}
</p>
</div>
{/* Stats Summary */}
{projectTechStats && (
<div className="text-sm text-muted-foreground pt-4 border-t border-border">
{projectTechStats.last_updated ? (
formatMessage(
{ id: 'specs.settings.devProgressStats', defaultMessage: '{total} entries from {sessions} sessions, last updated: {date}' },
{
total: projectTechStats.total_features,
sessions: projectTechStats.total_sessions,
date: new Date(projectTechStats.last_updated).toLocaleDateString()
}
)
) : (
formatMessage(
{ id: 'specs.settings.devProgressStatsNoDate', defaultMessage: '{total} entries from {sessions} sessions' },
{
total: projectTechStats.total_features,
sessions: projectTechStats.total_sessions
}
)
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -7340,11 +7340,13 @@ export interface InjectionPreviewResponse {
* @param mode - 'required' | 'all' | 'keywords'
* @param preview - Include content preview
* @param projectPath - Optional project path
* @param category - Optional category filter
*/
export async function getInjectionPreview(
mode: 'required' | 'all' | 'keywords' = 'required',
preview: boolean = false,
projectPath?: string
projectPath?: string,
category?: string
): Promise<InjectionPreviewResponse> {
const params = new URLSearchParams();
params.set('mode', mode);
@@ -7352,9 +7354,63 @@ export async function getInjectionPreview(
if (projectPath) {
params.set('path', projectPath);
}
if (category) {
params.set('category', category);
}
return fetchApi<InjectionPreviewResponse>(`/api/specs/injection-preview?${params.toString()}`);
}
/**
* Command preview configuration
*/
export interface CommandPreviewConfig {
command: string;
label: string;
description: string;
category?: string;
mode: 'required' | 'all';
}
/**
* Predefined command preview configurations
*/
export const COMMAND_PREVIEWS: CommandPreviewConfig[] = [
{
command: 'ccw spec load',
label: 'Default (All Categories)',
description: 'Load all required specs without category filter',
mode: 'required',
},
{
command: 'ccw spec load --category exploration',
label: 'Exploration',
description: 'Specs for code exploration, analysis, debugging',
category: 'exploration',
mode: 'required',
},
{
command: 'ccw spec load --category planning',
label: 'Planning',
description: 'Specs for task planning, requirements',
category: 'planning',
mode: 'required',
},
{
command: 'ccw spec load --category execution',
label: 'Execution',
description: 'Specs for implementation, testing, deployment',
category: 'execution',
mode: 'required',
},
{
command: 'ccw spec load --category general',
label: 'General',
description: 'Specs that apply to all stages',
category: 'general',
mode: 'required',
},
];
/**
* Update spec frontmatter (toggle readMode)
*/

View File

@@ -390,5 +390,42 @@
"enterprise": {
"label": "Enterprise",
"tooltip": "Enterprise MCP server"
},
"specs": {
"settings": {
"personalSpecDefaults": "Personal Spec Defaults",
"personalSpecDefaultsDesc": "These settings will be applied when creating new personal specs",
"defaultReadMode": "Default Read Mode",
"selectReadMode": "Select read mode",
"defaultReadModeHelp": "The default read mode for newly created personal specs",
"autoEnable": "Auto Enable New Specs",
"autoEnableDescription": "Automatically enable newly created personal specs",
"specStatistics": "Spec Statistics",
"totalSpecs": "Total: {count} spec files",
"required": "required",
"readMode": {
"required": "Required",
"optional": "Optional"
},
"dimension": {
"specs": "Project Specs",
"personal": "Personal Specs"
},
"devProgressInjection": "Development Progress Injection",
"devProgressInjectionDesc": "Control how development progress from project-tech.json is injected into AI context",
"enableDevProgress": "Enable Injection",
"enableDevProgressDesc": "Include development history in AI context",
"maxEntries": "Max Entries per Category",
"maxEntriesDesc": "Maximum number of entries to include per category (1-50)",
"includeCategories": "Include Categories",
"categoriesDesc": "Click to toggle category inclusion",
"devProgressStats": "{total} entries from {sessions} sessions, last updated: {date}",
"devProgressStatsNoDate": "{total} entries from {sessions} sessions"
},
"injection": {
"saveSuccess": "Settings saved successfully",
"saveError": "Failed to save settings: {error}",
"loadError": "Failed to load statistics"
}
}
}

View File

@@ -390,5 +390,42 @@
"enterprise": {
"label": "企业版",
"tooltip": "企业版 MCP 服务器"
},
"specs": {
"settings": {
"personalSpecDefaults": "个人规范默认设置",
"personalSpecDefaultsDesc": "这些设置将在创建新的个人规范时应用",
"defaultReadMode": "默认读取模式",
"selectReadMode": "选择读取模式",
"defaultReadModeHelp": "新创建的个人规范的默认读取模式",
"autoEnable": "自动启用新规范",
"autoEnableDescription": "自动启用新创建的个人规范",
"specStatistics": "规范统计",
"totalSpecs": "总计: {count} 个规范文件",
"required": "必读",
"readMode": {
"required": "必读",
"optional": "可选"
},
"dimension": {
"specs": "项目规范",
"personal": "个人规范"
},
"devProgressInjection": "开发进度注入",
"devProgressInjectionDesc": "控制如何将 project-tech.json 中的开发进度注入到 AI 上下文中",
"enableDevProgress": "启用注入",
"enableDevProgressDesc": "在 AI 上下文中包含开发历史",
"maxEntries": "每类别最大条目数",
"maxEntriesDesc": "每个类别包含的最大条目数 (1-50)",
"includeCategories": "包含类别",
"categoriesDesc": "点击切换类别包含状态",
"devProgressStats": "共 {total} 条记录来自 {sessions} 个会话,最后更新: {date}",
"devProgressStatsNoDate": "共 {total} 条记录来自 {sessions} 个会话"
},
"injection": {
"saveSuccess": "设置保存成功",
"saveError": "保存设置失败: {error}",
"loadError": "加载统计数据失败"
}
}
}

View File

@@ -43,6 +43,12 @@ const DEFAULT_PERSONAL_SPEC_DEFAULTS = {
autoEnable: true
};
const DEFAULT_DEV_PROGRESS_INJECTION = {
enabled: true,
maxEntriesPerCategory: 10,
categories: ['feature', 'enhancement', 'bugfix', 'refactor', 'docs']
};
// Recommended hooks for spec injection
const RECOMMENDED_HOOKS = [
{
@@ -90,6 +96,7 @@ function readSettingsFile(filePath: string): Record<string, unknown> {
function getSystemSettings(): {
injectionControl: typeof DEFAULT_INJECTION_CONTROL;
personalSpecDefaults: typeof DEFAULT_PERSONAL_SPEC_DEFAULTS;
devProgressInjection: typeof DEFAULT_DEV_PROGRESS_INJECTION;
recommendedHooks: typeof RECOMMENDED_HOOKS;
} {
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
@@ -105,6 +112,10 @@ function getSystemSettings(): {
...DEFAULT_PERSONAL_SPEC_DEFAULTS,
...((user.personalSpecDefaults || {}) as Record<string, unknown>)
} as typeof DEFAULT_PERSONAL_SPEC_DEFAULTS,
devProgressInjection: {
...DEFAULT_DEV_PROGRESS_INJECTION,
...((system.devProgressInjection || {}) as Record<string, unknown>)
} as typeof DEFAULT_DEV_PROGRESS_INJECTION,
recommendedHooks: RECOMMENDED_HOOKS
};
}
@@ -115,6 +126,7 @@ function getSystemSettings(): {
function saveSystemSettings(updates: {
injectionControl?: Partial<typeof DEFAULT_INJECTION_CONTROL>;
personalSpecDefaults?: Partial<typeof DEFAULT_PERSONAL_SPEC_DEFAULTS>;
devProgressInjection?: Partial<typeof DEFAULT_DEV_PROGRESS_INJECTION>;
}): { success: boolean; settings?: Record<string, unknown>; error?: string } {
try {
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
@@ -143,6 +155,14 @@ function saveSystemSettings(updates: {
};
}
if (updates.devProgressInjection) {
system.devProgressInjection = {
...DEFAULT_DEV_PROGRESS_INJECTION,
...((system.devProgressInjection || {}) as Record<string, unknown>),
...updates.devProgressInjection
};
}
// Ensure directory exists
const dirPath = dirname(GLOBAL_SETTINGS_PATH);
if (!existsSync(dirPath)) {
@@ -410,6 +430,7 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
const updates = body as {
injectionControl?: { maxLength?: number; warnThreshold?: number; truncateOnExceed?: boolean };
personalSpecDefaults?: { defaultReadMode?: string; autoEnable?: boolean };
devProgressInjection?: { enabled?: boolean; maxEntriesPerCategory?: number; categories?: string[] };
};
const result = saveSystemSettings(updates);
@@ -421,6 +442,73 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// API: Get project-tech stats for development progress injection
if (pathname === '/api/project-tech/stats' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const techPath = join(resolvedPath, '.workflow', 'project-tech.json');
if (!existsSync(techPath)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total_features: 0,
total_sessions: 0,
last_updated: null,
categories: {
feature: 0,
enhancement: 0,
bugfix: 0,
refactor: 0,
docs: 0
}
}));
return true;
}
try {
const rawContent = readFileSync(techPath, 'utf-8');
const tech = JSON.parse(rawContent) as {
development_index?: {
feature?: unknown[];
enhancement?: unknown[];
bugfix?: unknown[];
refactor?: unknown[];
docs?: unknown[];
};
_metadata?: {
last_updated?: string;
};
statistics?: {
total_features?: number;
total_sessions?: number;
};
};
const devIndex = tech.development_index || {};
const categories = {
feature: (devIndex.feature || []).length,
enhancement: (devIndex.enhancement || []).length,
bugfix: (devIndex.bugfix || []).length,
refactor: (devIndex.refactor || []).length,
docs: (devIndex.docs || []).length
};
const total_features = Object.values(categories).reduce((sum, count) => sum + count, 0);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total_features,
total_sessions: tech.statistics?.total_sessions || 0,
last_updated: tech._metadata?.last_updated || null,
categories
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Install recommended hooks
if (pathname === '/api/system/hooks/install-recommended' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {